@ttsc/evidence 0.26.2 → 0.28.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.
@@ -0,0 +1,121 @@
1
+ package evidence
2
+
3
+ import "github.com/samchon/ttsc/packages/lint/rule"
4
+
5
+ // GraphNodes publishes the artifacts this project's configured references
6
+ // selected, so a consumer can answer what a citation names rather than only who
7
+ // wrote it.
8
+ //
9
+ // It is a projection of the same corpus Hints projects, and it decides nothing:
10
+ // no coverage, no cardinality, no policy, no diagnostic crosses this boundary.
11
+ // Those are this rule's product and it already delivers them as compile errors;
12
+ // a consumer holding a second copy would hold an unmaintained one.
13
+ //
14
+ // Only selected units are published, which is what makes a document nobody
15
+ // configured a reference for contribute nothing, and what withdraws a unit that
16
+ // named the tag it hid itself behind: selection is where the rule's own answer
17
+ // about its surface already lives, so this asks it rather than repeating it.
18
+ //
19
+ // TypeScript units are not published. The graph already holds every TypeScript
20
+ // declaration as a real node resolved by the checker, and a second node for the
21
+ // same symbol under an evidence address would make one declaration two things.
22
+ func (graphRule) GraphNodes(ctx *rule.GraphContext) []rule.GraphNode {
23
+ if ctx == nil {
24
+ return nil
25
+ }
26
+ cycle, published := ctx.State.(*graphCycleState)
27
+ if !published || cycle == nil {
28
+ return nil
29
+ }
30
+ corpus := cycle.Corpus
31
+ // Addresses of every materialized unit, so a parent can be named by the
32
+ // address a citation would use rather than by the rule's internal id. A
33
+ // parent outside the selected set resolves to nothing and the host clears it,
34
+ // which leaves the child at the top of its chain instead of inventing one.
35
+ addresses := map[string]string{}
36
+ for _, inventories := range []map[string]*artifactInventory{
37
+ corpus.Markdown,
38
+ corpus.Prisma,
39
+ corpus.Swagger,
40
+ } {
41
+ for _, inventory := range inventories {
42
+ if inventory == nil {
43
+ continue
44
+ }
45
+ for _, unit := range inventory.Units {
46
+ if unit != nil {
47
+ addresses[unit.ID] = unit.Target
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ selected := selectedCompletionUnits(
54
+ corpus.Config,
55
+ corpus.Markdown,
56
+ corpus.Prisma,
57
+ corpus.Swagger,
58
+ false,
59
+ )
60
+ nodes := make([]rule.GraphNode, 0, len(selected))
61
+ for _, unit := range selected {
62
+ // No withdrawal check here on purpose. `selectedCompletionUnits` already
63
+ // excludes a unit that named the tag it hid itself behind, so a second check
64
+ // would be a branch nothing can reach — and an unreachable guard reads as a
65
+ // load-bearing one to the next author, who then trusts it instead of the
66
+ // selection that actually enforces it.
67
+ if unit == nil {
68
+ continue
69
+ }
70
+ kind, ok := graphNodeKindOf(unit)
71
+ if !ok {
72
+ continue
73
+ }
74
+ nodes = append(nodes, rule.GraphNode{
75
+ Address: unit.Target,
76
+ Kind: kind,
77
+ Readable: unit.Readable,
78
+ Parent: addresses[unit.ParentID],
79
+ File: unit.Path,
80
+ Line: unit.Line,
81
+ Aliases: unit.Aliases,
82
+ })
83
+ }
84
+ return nodes
85
+ }
86
+
87
+ // graphNodeKindOf maps one materialized unit onto the host's node vocabulary.
88
+ //
89
+ // The symbol is the rule's own selector, so this is a translation rather than a
90
+ // re-derivation: a Prisma relation is its own kind because the parser already
91
+ // separated it from a column, and a Markdown file is its own kind because the
92
+ // heading walk already separated it from a heading.
93
+ //
94
+ // A TypeScript unit reports false. So does an unrecognized symbol: guessing
95
+ // would publish a node under a kind a consumer would then rank and contain as
96
+ // something it is not.
97
+ func graphNodeKindOf(unit *evidenceUnit) (rule.GraphNodeKind, bool) {
98
+ switch unit.Type {
99
+ case artifactMarkdown:
100
+ if unit.Symbol == "file" {
101
+ return rule.GraphNodeMarkdownDocument, true
102
+ }
103
+ return rule.GraphNodeMarkdownSection, true
104
+ case artifactPrisma:
105
+ switch unit.Symbol {
106
+ case "model":
107
+ return rule.GraphNodePrismaModel, true
108
+ case "relation":
109
+ return rule.GraphNodePrismaRelation, true
110
+ case "column":
111
+ return rule.GraphNodePrismaColumn, true
112
+ }
113
+ return "", false
114
+ case artifactSwagger:
115
+ if unit.Symbol == "operation" {
116
+ return rule.GraphNodeSwaggerOperation, true
117
+ }
118
+ return "", false
119
+ }
120
+ return "", false
121
+ }
package/native/loader.go CHANGED
@@ -315,33 +315,50 @@ func (loader *typeScriptLoader) locateInstalledPackage(
315
315
  return path.Join("node_modules", name), nil
316
316
  }
317
317
 
318
- // resolveLinkedDirectory returns the directory a path ultimately names.
318
+ // resolveLinkedDirectory returns the directory a path ultimately names, and
319
+ // reports whether the chain ended inside the hops this rule follows.
319
320
  //
320
321
  // `filepath.EvalSymlinks` answers this for a POSIX symlink but not for a
321
322
  // Windows junction, which is exactly what pnpm creates for a workspace
322
323
  // dependency there: it returns the junction unchanged, so a caller that trusts
323
324
  // it walks the link and finds nothing. `os.Readlink` does report a junction's
324
325
  // target, and `os.Stat` sees through both, so ask those instead.
325
- func resolveLinkedDirectory(directory string) string {
326
+ //
327
+ // The second answer is what separates an exhausted chain from a resolved
328
+ // directory, which are one string without it: a caller comparing paths against
329
+ // a link that still names another link cannot tell that it holds one, and that
330
+ // is how the leaf case of #1269 stayed silent until the caller was told.
331
+ //
332
+ // The bound counts links followed, not answers given. A chain that ends exactly
333
+ // on the last hop this rule follows has landed on its directory with no
334
+ // iteration left to look, and it is a chain the filesystem resolves: Linux and
335
+ // Windows follow further, and Darwin stops at this same number, so the boundary
336
+ // sits where the strictest platform's does rather than inside it. So the answer
337
+ // costs one `os.Lstat` in that case and nothing at all in every other, since
338
+ // every earlier ending is a return from inside the loop. Refusing it instead
339
+ // would turn a root that works into an error at the boundary, which is what
340
+ // this rule exists to keep from happening in the other direction.
341
+ func resolveLinkedDirectory(directory string) (string, bool) {
326
342
  current := directory
327
343
  for range 32 {
328
344
  info, err := os.Lstat(current)
329
345
  if err != nil || info.IsDir() {
330
- return current
346
+ return current, true
331
347
  }
332
348
  if target, err := os.Stat(current); err != nil || !target.IsDir() {
333
- return current
349
+ return current, true
334
350
  }
335
351
  linked, err := os.Readlink(current)
336
352
  if err != nil {
337
- return current
353
+ return current, true
338
354
  }
339
355
  if !filepath.IsAbs(linked) {
340
356
  linked = filepath.Join(filepath.Dir(current), linked)
341
357
  }
342
358
  current = filepath.ToSlash(linked)
343
359
  }
344
- return current
360
+ info, err := os.Lstat(current)
361
+ return current, err == nil && info.IsDir()
345
362
  }
346
363
 
347
364
  // walk lists the project-relative TypeScript files below a directory.
@@ -357,7 +374,13 @@ func (loader *typeScriptLoader) walk(base string) ([]string, string) {
357
374
  // nothing, so walking the spelled path finds no files and the reference
358
375
  // looks empty rather than unresolvable. Walk what the link points at, and
359
376
  // report the files under the spelled path so addresses stay stable.
360
- walked := resolveLinkedDirectory(root)
377
+ // The bound is not refused here. A package whose chain outruns the resolver
378
+ // walks a link and matches nothing, and the caller already reports that as
379
+ // an empty population rather than passing over it in silence — so what is
380
+ // owed there is a better cause, not a diagnostic that does not exist. A
381
+ // declared base has no such report behind it, which is why
382
+ // `resolvedBaseDirectory` does refuse.
383
+ walked, _ := resolveLinkedDirectory(root)
361
384
  found := []string{}
362
385
  problem := ""
363
386
  err := filepath.WalkDir(walked, func(current string, entry fs.DirEntry, err error) error {
@@ -41,39 +41,64 @@ func loadMarkdownBase(
41
41
  inventories map[string]*artifactInventory,
42
42
  ) []string {
43
43
  problems := []string{}
44
- if problem := unreadableBaseProblem(base, artifactMarkdown); problem != "" {
44
+ if problem := baseDirectoryProblem(base, artifactMarkdown); problem != "" {
45
45
  recordPopulationFailure(inventories, artifactMarkdown, base)
46
46
  return []string{problem}
47
47
  }
48
- err := filepath.WalkDir(base.Absolute, func(current string, entry fs.DirEntry, walkErr error) error {
48
+ from, resolved := resolvedBaseDirectory(base)
49
+ if !resolved {
50
+ recordPopulationFailure(inventories, artifactMarkdown, base)
51
+ return []string{unresolvedBaseProblem(base, artifactMarkdown)}
52
+ }
53
+ err := filepath.WalkDir(from, func(current string, entry fs.DirEntry, walkErr error) error {
49
54
  if walkErr != nil {
50
- relative, ok := relativeProjectPath(base.Absolute, current)
51
- relevant := ok &&
52
- (matchesConfiguredMarkdownFile(config, base, relative) ||
53
- couldContainConfiguredMarkdown(config, base, relative))
54
- if !relevant {
55
- if entry != nil && entry.IsDir() {
56
- return filepath.SkipDir
57
- }
58
- return nil
55
+ // The walk root belongs to its population by construction, so a failure
56
+ // to list it is a failure of the population and is never decided by what
57
+ // the globs happen to select. The relevance test below answers for an
58
+ // entry inside the base, and it answers for the base itself only by
59
+ // accident: its base-relative path is ".", which `couldMatchDescendant`
60
+ // calls true under a pattern opening with `**` and false under one
61
+ // opening with a segment. So the one failure that empties the whole
62
+ // population was reported or discarded by the shape of the globs. The
63
+ // success branch already exempts the base; this is that exemption on the
64
+ // error side.
65
+ //
66
+ // Returning the error ends the walk and carries the failure to the
67
+ // handler below, which is where a population-level finding belongs and
68
+ // where the population is recorded failed rather than healthy and empty.
69
+ if current == from {
70
+ return walkErr
59
71
  }
60
- recordPopulationFailure(inventories, artifactMarkdown, base)
61
- problems = append(problems, "Evidence graph could not inspect '"+current+"': "+walkErr.Error()+". Fix filesystem access so configured Markdown sources can be indexed.")
62
- if entry != nil && entry.IsDir() {
63
- return filepath.SkipDir
72
+ problem, relevant := unreadableEntryProblem(
73
+ base,
74
+ from,
75
+ "Markdown",
76
+ current,
77
+ walkErr,
78
+ func(relative string) bool {
79
+ return matchesConfiguredMarkdownFile(config, base, relative) ||
80
+ couldContainConfiguredMarkdown(config, base, relative)
81
+ },
82
+ )
83
+ if relevant {
84
+ recordPopulationFailure(inventories, artifactMarkdown, base)
85
+ problems = append(problems, problem)
64
86
  }
65
- return nil
87
+ // `WalkDir` passes a nil entry only for its root, which the guard above
88
+ // answers, so this error belongs to a directory whose listing failed and
89
+ // the walk continues with its siblings.
90
+ return filepath.SkipDir
66
91
  }
67
92
  if entry.IsDir() {
68
- if current != base.Absolute {
69
- relative, ok := relativeProjectPath(base.Absolute, current)
93
+ if current != from {
94
+ relative, ok := relativeProjectPath(from, current)
70
95
  if !ok || !couldContainConfiguredMarkdown(config, base, relative) {
71
96
  return filepath.SkipDir
72
97
  }
73
98
  }
74
99
  return nil
75
100
  }
76
- relative, ok := relativeProjectPath(base.Absolute, current)
101
+ relative, ok := relativeProjectPath(from, current)
77
102
  if !ok {
78
103
  return nil
79
104
  }
@@ -88,13 +113,13 @@ func loadMarkdownBase(
88
113
  Type: artifactMarkdown,
89
114
  LoadFailed: true,
90
115
  }
91
- problems = append(problems, "Evidence graph could not read Markdown file '"+address.Display+"': "+readErr.Error()+". Fix filesystem access or exclude the file from configured globs.")
116
+ problems = append(problems, "Evidence graph could not read Markdown file '"+address.Display+"': "+causeText(readErr)+". Fix filesystem access or exclude the file from configured globs.")
92
117
  return nil
93
118
  }
94
119
  inventory, _ := scanMarkdownInventory(address, string(content))
95
120
  inventories[address.Key] = inventory
96
121
  for _, inventoryProblem := range inventory.Problems {
97
- if selectedByMarkdownReference(config, base, relative, inventoryProblem.Symbol) {
122
+ if selectedByMarkdownPopulation(config, base, relative, inventoryProblem.Symbol) {
98
123
  problems = append(problems, inventoryProblem.Message)
99
124
  }
100
125
  }
@@ -107,7 +132,7 @@ func loadMarkdownBase(
107
132
  })
108
133
  if err != nil {
109
134
  recordPopulationFailure(inventories, artifactMarkdown, base)
110
- problems = append(problems, "Evidence graph could not walk Markdown root '"+populationRootLabel(base)+"': "+err.Error()+".")
135
+ problems = append(problems, unlistableBaseProblem(base, "Markdown", err))
111
136
  }
112
137
  return problems
113
138
  }
@@ -231,14 +256,28 @@ func scanMarkdownInventory(
231
256
  }
232
257
  // A heading that materializes no unit still opens a region, and that
233
258
  // region's content belongs to the nearest heading unit enclosing it. An
234
- // H5, and an H2 whose title yields no anchor, are both such headings.
259
+ // H5 or deeper, and an H2 whose title yields no anchor, are both such
260
+ // headings.
235
261
  // Carrying the previous unit forward instead would attribute the region to
236
262
  // whatever unit the walk happened to see last, which is a sibling rather
237
263
  // than an ancestor when the skipped heading is shallower: editing text
238
264
  // under an anchorless H2 would then expire a review of the H3 above it,
239
265
  // which does not contain that text.
240
266
  currentDigestHostID = fileUnitID
241
- for ancestorLevel := level - 1; ancestorLevel >= 1; ancestorLevel-- {
267
+ // Start at the deepest level the array holds rather than at this
268
+ // heading's own. Only H1 through H4 ever write a slot, so a deeper
269
+ // heading's first candidate ancestor is H4, and reading from its own
270
+ // level indexed past the end: an H6 walked from 5 into a five-slot array
271
+ // and took the whole rule down before it materialized anything. An H5
272
+ // survived that only because it starts at the last valid slot.
273
+ //
274
+ // Clamp rather than widen the array. Widening would also seal it, since
275
+ // `markdownHeading` refuses a level past 6, so this is a choice about
276
+ // what the type says rather than about safety: the array is indexed by
277
+ // heading level and sized to hold every materializable one, so its length
278
+ // is the model. A wider array would carry slots nothing writes and stop
279
+ // saying so. It is not indexed by ordinal, so slot zero is unused.
280
+ for ancestorLevel := min(level-1, len(headingUnitIDs)-1); ancestorLevel >= 1; ancestorLevel-- {
242
281
  if headingUnitIDs[ancestorLevel] != "" {
243
282
  currentDigestHostID = headingUnitIDs[ancestorLevel]
244
283
  break
@@ -256,8 +295,12 @@ func scanMarkdownInventory(
256
295
  Message: problems[len(problems)-1],
257
296
  })
258
297
  } else {
298
+ // Clamped like the digest walk above, though the `level <= 4` around
299
+ // this block already bounds it. Both walks read the same array, so
300
+ // both state the same bound rather than one of them depending on a
301
+ // condition someone could move.
259
302
  parentID := fileUnitID
260
- for ancestorLevel := level - 1; ancestorLevel >= 1; ancestorLevel-- {
303
+ for ancestorLevel := min(level-1, len(headingUnitIDs)-1); ancestorLevel >= 1; ancestorLevel-- {
261
304
  if headingUnitIDs[ancestorLevel] != "" {
262
305
  parentID = headingUnitIDs[ancestorLevel]
263
306
  break
@@ -315,7 +358,6 @@ func scanMarkdownInventory(
315
358
  }
316
359
  for _, review := range parseReviews(comment) {
317
360
  inventory.Reviews = append(inventory.Reviews, &evidenceReview{
318
- HostID: hostIDAtLine[line-1],
319
361
  SemanticHostIDs: []string{hostIDAtLine[line-1]},
320
362
  Reviews: review.Reviews,
321
363
  Type: artifactMarkdown,
@@ -432,13 +474,34 @@ func couldContainConfiguredMarkdown(
432
474
  return false
433
475
  }
434
476
 
435
- func selectedByMarkdownReference(
477
+ // selectedByMarkdownPopulation reports whether any configured population reads
478
+ // this file for the symbol a scan problem was filed under.
479
+ //
480
+ // Claims are asked as well as references. A scan problem says the file
481
+ // materialized less than it looks like it should, and that is a hole on either
482
+ // side: a reference loses evidence units, while a claim loses the hosts that owe
483
+ // acknowledgements. The claim side was the silent one. A whitespace-named claim
484
+ // file forms no target, so it contributes no host, drops out of the obligation,
485
+ // and said nothing at all; the same file pointed at by a reference reported the
486
+ // path immediately.
487
+ //
488
+ // The claim is matched on its own symbol set for the reason a reference is: a
489
+ // problem about a kind this population does not read is not its problem. The
490
+ // wildcard symbol still reaches both, which is what carries the unaddressable
491
+ // path, since that one is about the file rather than about any heading kind.
492
+ func selectedByMarkdownPopulation(
436
493
  config graphConfig,
437
494
  base populationBase,
438
495
  path string,
439
496
  symbol string,
440
497
  ) bool {
441
498
  for _, claim := range config.Claims {
499
+ if claim.Type == artifactMarkdown &&
500
+ claim.Base.Absolute == base.Absolute &&
501
+ claim.Files.matches(path) &&
502
+ (symbol == "*" || claim.Symbols.contains(symbol)) {
503
+ return true
504
+ }
442
505
  for _, reference := range claim.References {
443
506
  if reference.Type == artifactMarkdown &&
444
507
  reference.Base.Absolute == base.Absolute &&
@@ -561,7 +624,7 @@ func reportUnreadableMarkdownTags(
561
624
  }
562
625
  rendered := false
563
626
  for index, rawLine := range lines {
564
- line := strings.TrimSuffix(rawLine, "
627
+ line := strings.TrimSuffix(rawLine, "\r")
565
628
  if opens, closes := renderedCodeEdges(line); opens || closes {
566
629
  rendered = opens
567
630
  continue
package/native/model.go CHANGED
@@ -66,6 +66,11 @@ type claimSpec struct {
66
66
  // the resolved Base because the two are produced at different times: the
67
67
  // spelling decodes from options alone, while the resolution needs a project
68
68
  // identity the decoder never sees.
69
+ //
70
+ // `Base.Declared` holds the same spelling once a project identity exists, and
71
+ // the two are not interchangeable. Read this one before resolution, which is
72
+ // what `ProjectInputs` does to publish a topology without touching the
73
+ // filesystem, and read the base's after it, which is what a diagnostic does.
69
74
  Root string
70
75
  Base populationBase
71
76
  Files globSet
@@ -112,6 +117,31 @@ type referencePolicy struct {
112
117
  // an `@evidenceReview` whose fingerprint matches the cited scope's current
113
118
  // content, so a review expires when the thing it reviewed moves.
114
119
  RequireReview bool
120
+ // Checklist moves the obligation from the reference to each selected claim
121
+ // host: every host answers every unit, rather than the population being
122
+ // discharged once by whichever host got there first.
123
+ //
124
+ // It is not a peer of the cardinality options even though it decodes beside
125
+ // them. Those tighten a count inside the existing per-reference obligation;
126
+ // this one gives the obligation a host dimension, which is why the duplicate
127
+ // and conflict keys below it become per host and why `UniqueEvidence` and
128
+ // `SingleEvidencePerSymbol` are refused alongside it rather than composed
129
+ // with it.
130
+ //
131
+ // It also takes the subtree away from a positive citation. A citation answers
132
+ // the item it names and nothing beneath it, and a target naming no item at all
133
+ // is refused outright as an aggregate.
134
+ //
135
+ // Both halves are needed, and only measuring shows why. Refusing the
136
+ // unselected ancestor alone left the default Markdown selector, where the file
137
+ // is itself an item, discharging every heading from one `@evidence
138
+ // docs/rules.md` — the option was a no-op in the first configuration an
139
+ // adopter writes, silently. Any selector admitting an ancestor beside a
140
+ // descendant opens the same hole.
141
+ //
142
+ // `@evidenceExclude` keeps the cascade, because "none of this applies here" is
143
+ // one decision however many items it covers.
144
+ Checklist bool
115
145
  }
116
146
 
117
147
  // evidenceReview is one verification statement bound to its host and target.
@@ -123,15 +153,10 @@ type referencePolicy struct {
123
153
  // its review spell one address, and resolving twice would let a review answer a
124
154
  // scope its citation does not name.
125
155
  type evidenceReview struct {
126
- // HostID is the source-position identity, kept only so a carrier with no
127
- // semantic identity can still be matched. It must not be the primary key: a
128
- // merged identity's halves sit at different positions and therefore carry
129
- // different HostIDs, so matching on it alone refuses a review written on
130
- // `namespace I` for a citation on `interface I` — placement the graph
131
- // elsewhere calls not worth a diagnostic, and which `evidence/review` accepts.
132
- HostID string
133
- // SemanticHostIDs are the selected graph identities this review is written on,
134
- // which is what a citation is matched by.
156
+ // SemanticHostIDs are the ledger keys this review is written on. They are
157
+ // normally selected graph identities; an unattached Prisma review uses its
158
+ // synthetic file identity so the declaration-side position fallback can meet
159
+ // it. A citation is matched by these keys.
135
160
  SemanticHostIDs []string
136
161
  // Reviews names which acknowledgement this review answers for. It is part of
137
162
  // the match, never inferred: verifying a citation and verifying an exclusion
@@ -275,8 +300,9 @@ type evidenceDeclaration struct {
275
300
  ID string
276
301
  HostID string
277
302
  // SemanticHostIDs names the selected graph identities that physically host
278
- // this declaration. HostID remains the source-position identity used only
279
- // for same-block duplicate detection; policy cardinality must not confuse a
303
+ // this declaration. HostID remains the source-position identity used for
304
+ // same-block duplicate detection and as the review lookup fallback for a
305
+ // valid carrier with no semantic host. Policy cardinality must not confuse a
280
306
  // declaration position with the public symbol identity it represents.
281
307
  SemanticHostIDs []string
282
308
  Type artifactKind
@@ -304,12 +330,19 @@ func (declaration *evidenceDeclaration) valid() bool {
304
330
  type artifactInventory struct {
305
331
  // Address is the population-relative identity used for units and
306
332
  // declarations. It differs from Path when a configured root moves the
307
- // address space while diagnostics remain project-relative.
333
+ // address space while a diagnostic keeps naming the file the way a reader
334
+ // opens it.
308
335
  Address string
309
- // Path is the location a diagnostic names: project-relative, ascending with
310
- // `..` when the file sits above the project root. It is not the key this
311
- // inventory is filed under — that key carries the population base as well,
312
- // because one file reached through two roots owns two sets of targets.
336
+ // Path is the source a diagnostic names. For a file it is spelled the way a
337
+ // reader opens it: project-relative, ascending with `..` when the file sits
338
+ // above the project root, and absolute when no relative spelling exists. A
339
+ // Swagger document is its configured source instead, which may be a URL, and
340
+ // the diagnostics for that kind print it through `displaySwaggerSource` rather
341
+ // than from here.
342
+ //
343
+ // It is not the key this inventory is filed under — that key carries the
344
+ // population base as well, because one file reached through two roots owns two
345
+ // sets of targets.
313
346
  Path string
314
347
  Type artifactKind
315
348
  Units []*evidenceUnit