@ttsc/evidence 0.25.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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +279 -0
  3. package/lib/index.d.ts +65 -0
  4. package/lib/index.js +87 -0
  5. package/lib/internal/loadPrismaModels.d.ts +1 -0
  6. package/lib/internal/loadPrismaModels.js +197 -0
  7. package/lib/internal/loadSwaggerOperations.d.ts +1 -0
  8. package/lib/internal/loadSwaggerOperations.js +156 -0
  9. package/lib/internal/normalizeSwaggerDocument.d.ts +1 -0
  10. package/lib/internal/normalizeSwaggerDocument.js +7 -0
  11. package/lib/structures/ITtscEvidenceDocumentedConfig.d.ts +32 -0
  12. package/lib/structures/ITtscEvidenceDocumentedConfig.js +2 -0
  13. package/lib/structures/ITtscEvidenceGraphClaim.d.ts +14 -0
  14. package/lib/structures/ITtscEvidenceGraphClaim.js +2 -0
  15. package/lib/structures/ITtscEvidenceGraphClaimBase.d.ts +102 -0
  16. package/lib/structures/ITtscEvidenceGraphClaimBase.js +2 -0
  17. package/lib/structures/ITtscEvidenceGraphConfig.d.ts +19 -0
  18. package/lib/structures/ITtscEvidenceGraphConfig.js +2 -0
  19. package/lib/structures/ITtscEvidenceGraphMarkdownClaim.d.ts +40 -0
  20. package/lib/structures/ITtscEvidenceGraphMarkdownClaim.js +2 -0
  21. package/lib/structures/ITtscEvidenceGraphMarkdownReference.d.ts +76 -0
  22. package/lib/structures/ITtscEvidenceGraphMarkdownReference.js +2 -0
  23. package/lib/structures/ITtscEvidenceGraphPrismaClaim.d.ts +65 -0
  24. package/lib/structures/ITtscEvidenceGraphPrismaClaim.js +2 -0
  25. package/lib/structures/ITtscEvidenceGraphPrismaReference.d.ts +75 -0
  26. package/lib/structures/ITtscEvidenceGraphPrismaReference.js +2 -0
  27. package/lib/structures/ITtscEvidenceGraphReference.d.ts +14 -0
  28. package/lib/structures/ITtscEvidenceGraphReference.js +2 -0
  29. package/lib/structures/ITtscEvidenceGraphReferenceBase.d.ts +73 -0
  30. package/lib/structures/ITtscEvidenceGraphReferenceBase.js +2 -0
  31. package/lib/structures/ITtscEvidenceGraphSwaggerReference.d.ts +39 -0
  32. package/lib/structures/ITtscEvidenceGraphSwaggerReference.js +2 -0
  33. package/lib/structures/ITtscEvidenceGraphTypeScriptClaim.d.ts +54 -0
  34. package/lib/structures/ITtscEvidenceGraphTypeScriptClaim.js +2 -0
  35. package/lib/structures/ITtscEvidenceGraphTypeScriptReference.d.ts +83 -0
  36. package/lib/structures/ITtscEvidenceGraphTypeScriptReference.js +2 -0
  37. package/lib/structures/index.d.ts +13 -0
  38. package/lib/structures/index.js +29 -0
  39. package/lib/typings/ITtscLintContributorRules.d.ts +34 -0
  40. package/lib/typings/ITtscLintContributorRules.js +2 -0
  41. package/lib/typings/ITtscLintRuleOptionsMap.d.ts +20 -0
  42. package/lib/typings/ITtscLintRuleOptionsMap.js +2 -0
  43. package/lib/typings/TtscEvidenceGraphMarkdownSymbol.d.ts +32 -0
  44. package/lib/typings/TtscEvidenceGraphMarkdownSymbol.js +2 -0
  45. package/lib/typings/TtscEvidenceGraphPrismaSymbol.d.ts +38 -0
  46. package/lib/typings/TtscEvidenceGraphPrismaSymbol.js +2 -0
  47. package/lib/typings/TtscEvidenceGraphTypeScriptSymbol.d.ts +64 -0
  48. package/lib/typings/TtscEvidenceGraphTypeScriptSymbol.js +2 -0
  49. package/lib/typings/index.d.ts +5 -0
  50. package/lib/typings/index.js +21 -0
  51. package/native/base.go +298 -0
  52. package/native/config.go +798 -0
  53. package/native/declaration.go +240 -0
  54. package/native/documented.go +353 -0
  55. package/native/exports.go +484 -0
  56. package/native/glob.go +207 -0
  57. package/native/graph.go +1409 -0
  58. package/native/health.go +45 -0
  59. package/native/hints.go +261 -0
  60. package/native/imports.go +373 -0
  61. package/native/inputs.go +125 -0
  62. package/native/loader.go +424 -0
  63. package/native/markdown.go +423 -0
  64. package/native/model.go +296 -0
  65. package/native/prisma.go +719 -0
  66. package/native/prisma_cache.go +213 -0
  67. package/native/prisma_locator.go +417 -0
  68. package/native/singular.go +369 -0
  69. package/native/swagger.go +331 -0
  70. package/native/swagger_cache.go +274 -0
  71. package/native/todo.go +138 -0
  72. package/native/typescript.go +1199 -0
  73. package/native/typescript_cache.go +96 -0
  74. package/package.json +64 -0
@@ -0,0 +1,240 @@
1
+ package evidence
2
+
3
+ import (
4
+ "strings"
5
+ "unicode"
6
+ )
7
+
8
+ type parsedDeclaration struct {
9
+ Tag tagKind
10
+ Target string
11
+ Reason string
12
+ LineOffset int
13
+ }
14
+
15
+ func parseDeclarations(comment string) []parsedDeclaration {
16
+ return parseCommentDeclarations(comment, false)
17
+ }
18
+
19
+ // hiddenDeclarationTags are the documentation tags that withdraw a declaration
20
+ // from the public surface. They are equivalent here: each one is a statement
21
+ // that the declaration is not API, and the graph has no reason to grade the
22
+ // three against each other.
23
+ var hiddenDeclarationTags = []string{"@internal", "@hidden", "@ignore"}
24
+
25
+ // commentHidingTag returns the tag by which a documentation comment withdraws
26
+ // its declaration, or an empty string.
27
+ //
28
+ // The tag has to open its own line, the way every other tag in these comments
29
+ // is recognized. A prose line mentioning `@internal` is describing something,
30
+ // not declaring it, and treating a substring match as a declaration would let a
31
+ // sentence silently delete an obligation. Text after the tag is a comment for
32
+ // humans and is not read.
33
+ func commentHidingTag(comment string) string {
34
+ comment = strings.TrimSpace(comment)
35
+ comment = strings.TrimPrefix(comment, "/**")
36
+ comment = strings.TrimPrefix(comment, "/*")
37
+ comment = strings.TrimSuffix(comment, "*/")
38
+ for _, rawLine := range strings.Split(comment, "\n") {
39
+ line := strings.TrimSpace(rawLine)
40
+ line = strings.TrimSpace(strings.TrimPrefix(line, "*"))
41
+ line = strings.TrimSpace(strings.TrimPrefix(line, "///"))
42
+ for _, tag := range hiddenDeclarationTags {
43
+ if line != tag && !strings.HasPrefix(line, tag+" ") {
44
+ continue
45
+ }
46
+ return tag
47
+ }
48
+ }
49
+ return ""
50
+ }
51
+
52
+ // parseCommentDeclarations reads every declaration one comment body carries.
53
+ //
54
+ // tagBoundaries decides whether a line opening with some other `@tag` ends the
55
+ // declaration above it. A JSDoc block sets it by its own syntax, and a Prisma
56
+ // doc comment needs it set for the same reason without carrying that syntax:
57
+ // `///` comments routinely hold `@stance`, `@namespace`, and other tools' tags,
58
+ // and without a boundary the first of them is swallowed into the reason of the
59
+ // citation above it.
60
+ func parseCommentDeclarations(
61
+ comment string,
62
+ tagBoundaries bool,
63
+ ) []parsedDeclaration {
64
+ trimmed := strings.TrimLeftFunc(comment, unicode.IsSpace)
65
+ leadingLines := strings.Count(comment[:len(comment)-len(trimmed)], "\n")
66
+ comment = trimmed
67
+ jsdoc := strings.HasPrefix(comment, "/**") || tagBoundaries
68
+ comment = strings.TrimPrefix(comment, "/**")
69
+ comment = strings.TrimPrefix(comment, "/*")
70
+ comment = strings.TrimSuffix(comment, "*/")
71
+ lines := strings.Split(comment, "\n")
72
+ type pendingDeclaration struct {
73
+ tag tagKind
74
+ body []string
75
+ lineOffset int
76
+ }
77
+ var pending *pendingDeclaration
78
+ parsed := []parsedDeclaration{}
79
+ flush := func() {
80
+ if pending == nil {
81
+ return
82
+ }
83
+ target, reason := splitDeclarationBody(strings.Join(pending.body, "\n"))
84
+ parsed = append(parsed, parsedDeclaration{
85
+ Tag: pending.tag,
86
+ Target: target,
87
+ Reason: reason,
88
+ LineOffset: leadingLines + pending.lineOffset,
89
+ })
90
+ pending = nil
91
+ }
92
+ for index, rawLine := range lines {
93
+ line := strings.TrimSpace(rawLine)
94
+ line = strings.TrimSpace(strings.TrimPrefix(line, "*"))
95
+ tag, body, found := declarationLine(line)
96
+ if found {
97
+ flush()
98
+ pending = &pendingDeclaration{
99
+ tag: tag,
100
+ body: []string{body},
101
+ lineOffset: index,
102
+ }
103
+ continue
104
+ }
105
+ if jsdoc && strings.HasPrefix(line, "@") {
106
+ flush()
107
+ continue
108
+ }
109
+ if pending != nil {
110
+ pending.body = append(pending.body, line)
111
+ }
112
+ }
113
+ flush()
114
+ return parsed
115
+ }
116
+
117
+ func declarationLine(line string) (tagKind, string, bool) {
118
+ for _, candidate := range []struct {
119
+ marker string
120
+ tag tagKind
121
+ }{
122
+ {marker: "@evidenceExclude", tag: tagExclude},
123
+ {marker: "@evidence", tag: tagEvidence},
124
+ } {
125
+ if !strings.HasPrefix(line, candidate.marker) {
126
+ continue
127
+ }
128
+ remainder := line[len(candidate.marker):]
129
+ if remainder != "" && remainder[0] != ' ' && remainder[0] != '\t' {
130
+ continue
131
+ }
132
+ return candidate.tag, strings.TrimSpace(remainder), true
133
+ }
134
+ return "", "", false
135
+ }
136
+
137
+ // inlineLinkTags are the JSDoc inline link forms a TypeScript target may use.
138
+ //
139
+ // TypeScript resolves the name inside one of these and counts it as a use,
140
+ // which is the only reason a citation-only import survives `noUnusedLocals`.
141
+ // No other tag earns that, so no other tag may open a target.
142
+ var inlineLinkTags = []string{"{@linkcode", "{@linkplain", "{@link"}
143
+
144
+ func splitDeclarationBody(body string) (string, string) {
145
+ body = strings.TrimSpace(body)
146
+ if body == "" {
147
+ return "", ""
148
+ }
149
+ if target, reason, found := splitInlineLinkBody(body); found {
150
+ return target, reason
151
+ }
152
+ for index, char := range body {
153
+ if unicode.IsSpace(char) {
154
+ return body[:index], strings.TrimSpace(body[index:])
155
+ }
156
+ }
157
+ return body, ""
158
+ }
159
+
160
+ // splitInlineLinkBody consumes a braced target through its closing brace.
161
+ //
162
+ // The brace supplies the boundary a whitespace-delimited token cannot, so the
163
+ // grammar stays self-discriminating: the parser decides which resolver applies
164
+ // from the token alone, with no reference context. That is what keeps the
165
+ // `POST /members` hazard from recurring in a new form.
166
+ func splitInlineLinkBody(body string) (string, string, bool) {
167
+ marker := ""
168
+ for _, candidate := range inlineLinkTags {
169
+ if strings.HasPrefix(body, candidate) {
170
+ marker = candidate
171
+ break
172
+ }
173
+ }
174
+ if marker == "" {
175
+ return "", "", false
176
+ }
177
+ closing := strings.IndexByte(body, '}')
178
+ if closing < 0 {
179
+ // An unterminated link is a malformed declaration rather than a plain
180
+ // token: reporting it as the target `{@link` would name a repair the
181
+ // author cannot make.
182
+ return "", "", false
183
+ }
184
+ inner := strings.TrimSpace(body[len(marker):closing])
185
+ reason := strings.TrimSpace(body[closing+1:])
186
+ if inner == "" || containsWhitespace(inner) {
187
+ return "", "", false
188
+ }
189
+ return inlineLinkPrefix + inner, reason, true
190
+ }
191
+
192
+ // inlineLinkPrefix marks a parsed target as import-scope resolved.
193
+ //
194
+ // Carrying the discrimination in the value keeps every downstream consumer —
195
+ // resolution, diagnostics, duplicate detection — reading one field instead of
196
+ // re-parsing the comment to recover what the parser already knew.
197
+ const inlineLinkPrefix = "\x00link:"
198
+
199
+ func isInlineLinkTarget(target string) bool {
200
+ return strings.HasPrefix(target, inlineLinkPrefix)
201
+ }
202
+
203
+ func inlineLinkTarget(target string) string {
204
+ return strings.TrimPrefix(target, inlineLinkPrefix)
205
+ }
206
+
207
+ // displayTarget renders a target the way its author wrote it.
208
+ func displayTarget(target string) string {
209
+ if isInlineLinkTarget(target) {
210
+ return "{@link " + inlineLinkTarget(target) + "}"
211
+ }
212
+ return target
213
+ }
214
+
215
+ func normalizeMarkdownTarget(target string) string {
216
+ target = strings.ReplaceAll(target, "\\", "/")
217
+ for strings.HasPrefix(target, "./") {
218
+ target = strings.TrimPrefix(target, "./")
219
+ }
220
+ return target
221
+ }
222
+
223
+ func containsWhitespace(value string) bool {
224
+ for _, char := range value {
225
+ if unicode.IsSpace(char) {
226
+ return true
227
+ }
228
+ }
229
+ return false
230
+ }
231
+
232
+ func lineAt(content string, offset int) int {
233
+ if offset < 0 {
234
+ return 1
235
+ }
236
+ if offset > len(content) {
237
+ offset = len(content)
238
+ }
239
+ return 1 + strings.Count(content[:offset], "\n")
240
+ }
@@ -0,0 +1,353 @@
1
+ package evidence
2
+
3
+ import (
4
+ "encoding/json"
5
+ "sort"
6
+ "strings"
7
+
8
+ shimast "github.com/microsoft/typescript-go/shim/ast"
9
+
10
+ "github.com/samchon/ttsc/packages/lint/rule"
11
+ )
12
+
13
+ // documentedRule requires a JSDoc block on every selected export.
14
+ //
15
+ // Evidence declarations are read from one place only — the JSDoc blocks a node
16
+ // reports — so an export without a block is not merely undocumented, it is
17
+ // structurally unable to carry a citation. The graph never says so: coverage is
18
+ // counted from the evidence side, so an obligation is discharged by whichever
19
+ // host does have a block while every undocumented export in the same claim
20
+ // contributes nothing and looks exactly like a passing file.
21
+ type documentedRule struct{}
22
+
23
+ type documentedOptions struct {
24
+ Symbol json.RawMessage `json:"symbol"`
25
+ }
26
+
27
+ func (documentedRule) Name() string { return documentedRuleName }
28
+
29
+ func (documentedRule) Visits() []shimast.Kind {
30
+ return []shimast.Kind{shimast.KindSourceFile}
31
+ }
32
+
33
+ func (documentedRule) NeedsTypeChecker() bool { return false }
34
+
35
+ func (documentedRule) VisitsDeclarationFiles() bool { return false }
36
+
37
+ func (documentedRule) Check(ctx *rule.Context, node *shimast.Node) {
38
+ if ctx == nil || ctx.File == nil || node == nil {
39
+ return
40
+ }
41
+ if node.Kind != shimast.KindSourceFile {
42
+ return
43
+ }
44
+ selected, problems := decodeDocumentedOptions(ctx.Options)
45
+ if len(problems) != 0 {
46
+ for _, problem := range problems {
47
+ if !firstDocumentedCycleDiagnostic(ctx, problem) {
48
+ continue
49
+ }
50
+ ctx.Report(node, problem)
51
+ }
52
+ return
53
+ }
54
+ for _, host := range documentedHosts(ctx.File) {
55
+ if !selected.contains(host.Symbol) {
56
+ continue
57
+ }
58
+ blocks := host.documentation(ctx.File)
59
+ // Every finding anchors on the declaration rather than on a block.
60
+ // `Report` skips a node's leading trivia, and a JSDoc node is entirely
61
+ // trivia to the declaration it precedes, so anchoring there asks the
62
+ // host to underline the range it is built to skip past.
63
+ switch {
64
+ case len(blocks.Content) != 0:
65
+ continue
66
+ case len(blocks.Empty) != 0:
67
+ ctx.Report(
68
+ host.Node,
69
+ "Empty JSDoc on "+host.describe()+
70
+ ". The block states nothing and carries no tag, so it documents nothing and cites nothing. Describe what this declaration is for, or cite its evidence with '@evidence <target> <reason>'.",
71
+ )
72
+ default:
73
+ ctx.Report(
74
+ host.Node,
75
+ "Missing JSDoc on "+host.describe()+
76
+ ". An '@evidence' tag is only ever read from a JSDoc block, so without one this declaration can never cite anything. Add a '/** ... */' block above it.",
77
+ )
78
+ }
79
+ }
80
+ }
81
+
82
+ // firstDocumentedCycleDiagnostic deduplicates configuration failures through
83
+ // the graph project rule's state, the only Program-cycle value the contributor
84
+ // API exposes to a file rule.
85
+ //
86
+ // Consumers normally enable documented beside graph, as the package's canonical
87
+ // configuration does. If graph is absent or off there is no cycle identity to
88
+ // coordinate through; reporting in every visited file is deliberately safer
89
+ // than suppressing a configuration failure across an unknown later cycle.
90
+ func firstDocumentedCycleDiagnostic(ctx *rule.Context, message string) bool {
91
+ if ctx == nil {
92
+ return false
93
+ }
94
+ result := ctx.ProjectResult(graphRuleName)
95
+ cycle, ok := result.State.(*graphCycleState)
96
+ if !ok || cycle == nil {
97
+ return true
98
+ }
99
+ return cycle.Diagnostics.first(documentedRuleName + "\x00" + message)
100
+ }
101
+
102
+ func init() { rule.Register(documentedRule{}) }
103
+
104
+ func decodeDocumentedOptions(raw json.RawMessage) (symbolSet, []string) {
105
+ options := documentedOptions{}
106
+ if len(strings.TrimSpace(string(raw))) != 0 {
107
+ if err := json.Unmarshal(raw, &options); err != nil {
108
+ return nil, []string{
109
+ "Invalid evidence/documented configuration: expected an ITtscEvidenceDocumentedConfig object, or a bare severity for the default selection.",
110
+ }
111
+ }
112
+ if problems := rejectUnknownDocumentedFields(raw); len(problems) != 0 {
113
+ return nil, problems
114
+ }
115
+ }
116
+ selected, problems := decodeSymbols(
117
+ options.Symbol,
118
+ artifactTypeScript,
119
+ false,
120
+ documentedRuleName,
121
+ "symbol",
122
+ )
123
+ if len(problems) != 0 {
124
+ return nil, problems
125
+ }
126
+ return selected, nil
127
+ }
128
+
129
+ func rejectUnknownDocumentedFields(raw json.RawMessage) []string {
130
+ object, problem := decodeObject(raw, "configuration")
131
+ if problem != "" {
132
+ return []string{
133
+ "Invalid evidence/documented configuration: expected an ITtscEvidenceDocumentedConfig object, or a bare severity for the default selection.",
134
+ }
135
+ }
136
+ return rejectUnknownFields(
137
+ object,
138
+ []string{"symbol"},
139
+ documentedRuleName,
140
+ "configuration",
141
+ )
142
+ }
143
+
144
+ // documentedHost is one public identity that must carry a JSDoc block.
145
+ //
146
+ // The unit judged is an identity, never a declaration node. Declaration merging
147
+ // and overload sets give one identity several nodes — `interface I` beside
148
+ // `namespace I`, a callable beside its signatures — and judging the nodes would
149
+ // demand a block on every half of the very idiom `evidence/singular` blesses.
150
+ //
151
+ // Nodes holds them in source order, so Node is the declaration that founds the
152
+ // identity and the only one the rule reads.
153
+ type documentedHost struct {
154
+ Node *shimast.Node
155
+ Nodes []*shimast.Node
156
+ Symbol string
157
+ Targets []string
158
+ }
159
+
160
+ // documentationBlocks separates the blocks on one declaration by whether they
161
+ // say anything.
162
+ //
163
+ // Only a block with content documents, and only a block with content can hold a
164
+ // citation. An empty block keeps its own finding, because telling an author a
165
+ // block is missing while they are looking straight at one helps nobody.
166
+ type documentationBlocks struct {
167
+ Content []*shimast.Node
168
+ Empty []*shimast.Node
169
+ }
170
+
171
+ // documentation reads the blocks on the declaration that founds this identity.
172
+ //
173
+ // Only the first declaration is read. A merged identity is whatever its first
174
+ // declaration says it is, so a block on a later half neither satisfies the
175
+ // first nor counts against it — one unconditional rule, with nothing further to
176
+ // police.
177
+ func (host documentedHost) documentation(
178
+ file *shimast.SourceFile,
179
+ ) documentationBlocks {
180
+ blocks := documentationBlocks{}
181
+ if host.Node == nil {
182
+ return blocks
183
+ }
184
+ content := file.Text()
185
+ for _, doc := range host.Node.JSDoc(file) {
186
+ if doc == nil || doc.Pos() < 0 || doc.End() > len(content) {
187
+ continue
188
+ }
189
+ if jsdocHasContent(content[doc.Pos():doc.End()]) {
190
+ blocks.Content = append(blocks.Content, doc)
191
+ continue
192
+ }
193
+ blocks.Empty = append(blocks.Empty, doc)
194
+ }
195
+ return blocks
196
+ }
197
+
198
+ func (host documentedHost) describe() string {
199
+ return "exported " + host.Symbol + " '" +
200
+ strings.Join(host.Targets, "', '") + "'"
201
+ }
202
+
203
+ // documentedHosts lists the public identities of a file in source order.
204
+ //
205
+ // Both the population and its qualified names come from the collector
206
+ // `evidence/graph` uses. The population that must be able to hold a tag is by
207
+ // definition the population a claim can select as a host, and the name a
208
+ // diagnostic asks the author to cite has to be the name the graph resolves — a
209
+ // second walk here would let either drift, and the rule would then guarantee
210
+ // the wrong set under the wrong names.
211
+ func documentedHosts(file *shimast.SourceFile) []documentedHost {
212
+ inventory := &artifactInventory{
213
+ Type: artifactTypeScript,
214
+ UnitNodes: map[string][]*shimast.Node{},
215
+ }
216
+ supported := map[*shimast.Node]symbolSet{}
217
+ collectTypeScriptStatements(
218
+ file,
219
+ file.Statements,
220
+ nil,
221
+ "",
222
+ inventory,
223
+ supported,
224
+ map[string]*evidenceUnit{},
225
+ file.IsDeclarationFile,
226
+ false,
227
+ false,
228
+ "",
229
+ )
230
+ hosts := make([]documentedHost, 0, len(inventory.Units))
231
+ for _, unit := range inventory.Units {
232
+ if unit.Hidden != "" {
233
+ continue
234
+ }
235
+ nodes := hostNodesOf(inventory.UnitNodes[unit.ID], supported, unit.Symbol)
236
+ if len(nodes) == 0 {
237
+ continue
238
+ }
239
+ hosts = append(hosts, documentedHost{
240
+ Node: nodes[0],
241
+ Nodes: nodes,
242
+ Symbol: unit.Symbol,
243
+ Targets: []string{unit.Target},
244
+ })
245
+ }
246
+ sort.Slice(hosts, func(left int, right int) bool {
247
+ return hosts[left].Node.Pos() < hosts[right].Node.Pos()
248
+ })
249
+ return orderIdentityDeclarations(mergeSharedBlockHosts(hosts))
250
+ }
251
+
252
+ // hostNodesOf keeps the declarations that can actually carry a citation.
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.
261
+ //
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.
265
+ func hostNodesOf(
266
+ nodes []*shimast.Node,
267
+ supported map[*shimast.Node]symbolSet,
268
+ symbol string,
269
+ ) []*shimast.Node {
270
+ hosts := make([]*shimast.Node, 0, len(nodes))
271
+ for _, node := range nodes {
272
+ if supported[node].contains(symbol) {
273
+ hosts = append(hosts, node)
274
+ }
275
+ }
276
+ return hosts
277
+ }
278
+
279
+ // orderIdentityDeclarations puts each identity's declarations in source order.
280
+ //
281
+ // The collector records them as it walks, and the merged forms are folded in
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
285
+ // TypeScript actually attaches the block.
286
+ func orderIdentityDeclarations(hosts []documentedHost) []documentedHost {
287
+ for index := range hosts {
288
+ nodes := hosts[index].Nodes
289
+ sort.SliceStable(nodes, func(left int, right int) bool {
290
+ return nodes[left].Pos() < nodes[right].Pos()
291
+ })
292
+ if len(nodes) != 0 {
293
+ hosts[index].Node = nodes[0]
294
+ }
295
+ }
296
+ return hosts
297
+ }
298
+
299
+ // mergeSharedBlockHosts folds identities that can only ever share one block.
300
+ //
301
+ // `export const a = 1, b = 2;` declares two identities, and TypeScript gives
302
+ // the statement a single JSDoc position serving both. They are two obligations
303
+ // with one repair, so reporting each separately tells the author the same thing
304
+ // twice — the duplication this project's diagnostics deliberately avoid.
305
+ func mergeSharedBlockHosts(hosts []documentedHost) []documentedHost {
306
+ merged := make([]documentedHost, 0, len(hosts))
307
+ byStatement := map[*shimast.Node]int{}
308
+ for _, host := range hosts {
309
+ statement := sharedVariableStatement(host)
310
+ if statement == nil {
311
+ merged = append(merged, host)
312
+ continue
313
+ }
314
+ if index, seen := byStatement[statement]; seen &&
315
+ merged[index].Symbol == host.Symbol {
316
+ merged[index].Targets = append(merged[index].Targets, host.Targets...)
317
+ continue
318
+ }
319
+ byStatement[statement] = len(merged)
320
+ merged = append(merged, host)
321
+ }
322
+ return merged
323
+ }
324
+
325
+ func sharedVariableStatement(host documentedHost) *shimast.Node {
326
+ for _, node := range host.Nodes {
327
+ if node != nil && node.Kind == shimast.KindVariableStatement {
328
+ return node
329
+ }
330
+ }
331
+ return nil
332
+ }
333
+
334
+ // jsdocHasContent reports whether a block says anything at all.
335
+ //
336
+ // Blocks are read through the same accessor the tag collector uses, so the rule
337
+ // accepts exactly what a citation could be found in. Accepting anything wider
338
+ // would pass a comment the graph cannot read, which is the silence this rule
339
+ // exists to remove.
340
+ func jsdocHasContent(comment string) bool {
341
+ comment = strings.TrimSpace(comment)
342
+ comment = strings.TrimPrefix(comment, "/**")
343
+ comment = strings.TrimPrefix(comment, "/*")
344
+ comment = strings.TrimSuffix(comment, "*/")
345
+ for _, line := range strings.Split(comment, "\n") {
346
+ line = strings.TrimSpace(line)
347
+ line = strings.TrimSpace(strings.TrimPrefix(line, "*"))
348
+ if line != "" {
349
+ return true
350
+ }
351
+ }
352
+ return false
353
+ }