@ttsc/evidence 0.27.0 → 0.28.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/base.go CHANGED
@@ -1,6 +1,8 @@
1
1
  package evidence
2
2
 
3
3
  import (
4
+ "errors"
5
+ "io/fs"
4
6
  "os"
5
7
  "path"
6
8
  "path/filepath"
@@ -29,10 +31,22 @@ type populationBase struct {
29
31
  // Absolute is the canonical directory, and the identity two populations are
30
32
  // judged the same base by.
31
33
  Absolute string
32
- // Display is what a diagnostic names: project-relative, ascending with `..`
34
+ // Declared is the author's own spelling of this base, empty for the default
35
+ // base. It sits beside the derived Display because a message that names a
36
+ // configuration property and a message that names a location want different
37
+ // spellings; `populationRootLabel` owns that split and the reasoning for it.
38
+ //
39
+ // It arrives already normalized and is stored untouched. `normalizeRootPath`
40
+ // owns that step, refusing the two forms a root may not take and reducing the
41
+ // rest to one slash-separated spelling before a project identity exists to
42
+ // resolve against — so a second normalization here would be a branch no
43
+ // configuration can reach.
44
+ Declared string
45
+ // Display is what a location names: project-relative, ascending with `..`
33
46
  // when the base sits above the project, and absolute only when no relative
34
- // spelling exists. Empty for the default base, whose files are already named
35
- // by their project-relative path.
47
+ // spelling exists, which on Windows is another volume or a UNC share against a
48
+ // drive-lettered project. Empty for the default base, whose files are already
49
+ // named by their project-relative path.
36
50
  Display string
37
51
  // Default marks the base a population takes when it declares no root. Its
38
52
  // addresses stay plain project-relative paths, which is what leaves every
@@ -64,10 +78,20 @@ func (base populationBase) addressOf(relative string) artifactAddress {
64
78
  }
65
79
 
66
80
  // display spells a base-relative path the way a reader must open it.
81
+ //
82
+ // The separator is added only where one is missing. A base with no relative
83
+ // spelling, which on Windows is another volume or a UNC share against a
84
+ // drive-lettered project, carries its absolute path as its Display, and a drive
85
+ // root is the one directory whose own separator is part of it: joining `D:/` to
86
+ // a path without this would print `D://requirements`, in every file location as
87
+ // well as in the two walk messages.
67
88
  func (base populationBase) display(relative string) string {
68
89
  if base.Display == "" {
69
90
  return relative
70
91
  }
92
+ if strings.HasSuffix(base.Display, "/") {
93
+ return base.Display + relative
94
+ }
71
95
  return base.Display + "/" + relative
72
96
  }
73
97
 
@@ -127,13 +151,16 @@ func resolveProjectPath(root string, relative string) string {
127
151
  // A declared root that resolves back onto the project root is the default base
128
152
  // rather than a second one spelled differently, so `root: "."` and an omitted
129
153
  // root produce one population instead of two that address the same files
130
- // differently.
154
+ // differently. That collapse is also why only a non-default base carries a
155
+ // declared spelling: the base every un-rooted population shares names no
156
+ // configuration property, and giving it one would be a second identity for the
157
+ // one thing this branch exists to keep single.
131
158
  func resolvePopulationBase(root string, declared string) populationBase {
132
159
  if declared == "" {
133
160
  return populationBase{Absolute: root, Default: true}
134
161
  }
135
162
  absolute := filepath.FromSlash(declared)
136
- if !filepath.IsAbs(absolute) {
163
+ if !declaredRootIsAbsolute(declared) {
137
164
  absolute = filepath.Join(root, absolute)
138
165
  }
139
166
  absolute = filepath.Clean(absolute)
@@ -142,16 +169,41 @@ func resolvePopulationBase(root string, declared string) populationBase {
142
169
  }
143
170
  return populationBase{
144
171
  Absolute: absolute,
172
+ Declared: declared,
145
173
  Display: projectRelativeDisplay(root, absolute),
146
174
  }
147
175
  }
148
176
 
177
+ // declaredRootIsAbsolute reports whether a declared root names its own location
178
+ // rather than one below the ttsc project root.
179
+ //
180
+ // One predicate answers this for the resolution and for every message that
181
+ // describes it, so the two cannot disagree. A sentence telling an author that
182
+ // their root resolves against the project root, printed over a path the
183
+ // resolution never joined to anything, is the false clause this diagnostic
184
+ // exists to remove — and it would return the moment a message re-derived the
185
+ // question with a test of its own.
186
+ //
187
+ // Windows answers it the way `filepath.Join` does: a rooted path carrying no
188
+ // volume, such as `/srv/contracts`, is relative there and is joined to the
189
+ // project, so a message reading this predicate keeps telling the truth on that
190
+ // platform while one reading the spelling would not.
191
+ func declaredRootIsAbsolute(declared string) bool {
192
+ return filepath.IsAbs(filepath.FromSlash(declared))
193
+ }
194
+
149
195
  // projectRelativeDisplay spells a directory the way the project sees it.
150
196
  //
151
197
  // A relative spelling is preferred even when it ascends, because every other
152
- // path this rule prints is project-relative and a reader compares them against
153
- // each other. An absolute path appears only when no relative one exists — a
154
- // different Windows volume — where the alternative would be no spelling at all.
198
+ // location this rule prints is project-relative and a reader compares them
199
+ // against each other. An absolute path appears only when no relative one exists
200
+ // — a different Windows volume, or a UNC share against a drive-letter project —
201
+ // where the alternative would be no spelling at all.
202
+ //
203
+ // The `root` property a diagnostic asks an author to correct is the one printed
204
+ // path this no longer decides. `populationRootLabel` spells that as the
205
+ // configuration does, because it is a different reader doing a different thing:
206
+ // editing a file rather than opening one.
155
207
  func projectRelativeDisplay(root string, absolute string) string {
156
208
  relative, err := filepath.Rel(root, absolute)
157
209
  if err != nil {
@@ -187,6 +239,14 @@ func resolveGraphBases(root string, config *graphConfig) {
187
239
  // Order is fixed rather than incidental because a loader walks these in
188
240
  // sequence and its diagnostics are reported in the order they were produced. A
189
241
  // map iteration here would make an unreadable root's message move between runs.
242
+ //
243
+ // Two populations that reach one directory through different spellings are one
244
+ // base, and the surviving spelling is the first one in configuration order,
245
+ // because that is the order this loop admits them in and the `seen` map only
246
+ // answers membership. That decides only the loader-level root messages, which
247
+ // belong to the directory rather than to either declaration. Every per-
248
+ // population diagnostic reads that population's own base and therefore keeps
249
+ // naming the spelling its own author wrote.
190
250
  func configuredBases(config graphConfig, kind artifactKind) []populationBase {
191
251
  bases := []populationBase{}
192
252
  seen := map[string]bool{}
@@ -213,26 +273,52 @@ func configuredBases(config graphConfig, kind artifactKind) []populationBase {
213
273
  return bases
214
274
  }
215
275
 
216
- // populationRootLabel names a base the way a reader has to think about it: the
217
- // project-relative spelling when one exists, and the absolute path otherwise.
276
+ // populationRootLabel names the configuration property a reader has to edit,
277
+ // spelled the way they wrote it.
278
+ //
279
+ // This is the one place a declared spelling is preferred over the derived one,
280
+ // and the split is between two questions rather than two formats. A message
281
+ // naming a *location* spells it the way the reader opens it, which is what
282
+ // `display` does, and which is project-relative wherever a relative spelling
283
+ // exists. A
284
+ // message naming a *configuration property* spells it the way the configuration
285
+ // does, because the author's first move is to search `lint.config.ts` for it —
286
+ // and the derived spelling of an absolute root is an ascending path that file
287
+ // does not contain.
288
+ //
289
+ // The absolute fallback belongs to the default base alone, which is the only
290
+ // base with no declared spelling, and the project root is then the only thing
291
+ // left to name. Of the four callers, two reach it: `unlistableBaseProblem` and
292
+ // `unresolvedBaseProblem`, both of which a base with no declared root can arrive
293
+ // at. `describePopulation` returns on the default base before it names anything,
294
+ // and `describeBaseDirectoryProblem` is only ever entered through
295
+ // `baseDirectoryProblem`, which does the same. Neither of the two that reach it
296
+ // asks for an edit to a property that is not there, which is what makes the
297
+ // spelling usable where no property exists.
218
298
  func populationRootLabel(base populationBase) string {
219
- if base.Display == "" {
299
+ if base.Declared == "" {
220
300
  return filepath.ToSlash(base.Absolute)
221
301
  }
222
- return base.Display
302
+ return base.Declared
223
303
  }
224
304
 
225
- // unreadableBaseProblem reports a declared root that is not a readable
226
- // directory.
305
+ // baseDirectoryProblem reports a declared root that is not a usable directory.
306
+ //
307
+ // The observation and the sentence are separate functions because the sentence
308
+ // branches on the artifact kind as well as on what the stat found, while the
309
+ // observation is one call, and because only the sentence can be put in front of
310
+ // a reader without a filesystem in the state it describes.
311
+ //
312
+ // The name says neither "missing" nor "read". Not missing, because the path may
313
+ // be occupied by a file or hidden behind a parent this process may not enter,
314
+ // and both of those are present. Not read, because the third of its three
315
+ // callers materializes from the Program and reads nothing while asking the same
316
+ // question the two walkers ask.
227
317
  //
228
318
  // The default base is excluded because `Check` already validated the project
229
319
  // root, and its diagnostic names the ttsc project identity as the repair rather
230
320
  // than a configuration property that does not exist there.
231
- //
232
- // Both spellings appear. The relative one is the property the author has to
233
- // edit, and the absolute one is where that property actually landed — which is
234
- // the whole question the moment a root ascends out of the project.
235
- func unreadableBaseProblem(base populationBase, kind artifactKind) string {
321
+ func baseDirectoryProblem(base populationBase, kind artifactKind) string {
236
322
  if base.Default {
237
323
  return ""
238
324
  }
@@ -240,9 +326,418 @@ func unreadableBaseProblem(base populationBase, kind artifactKind) string {
240
326
  if err == nil && info.IsDir() {
241
327
  return ""
242
328
  }
243
- return "Evidence graph could not read the " + string(kind) + " root '" +
244
- populationRootLabel(base) + "', which resolves to '" + filepath.ToSlash(base.Absolute) +
245
- "'. Create the directory, or correct the 'root' property; it resolves against the ttsc project root."
329
+ // A link with no target is the one state where the two calls disagree about
330
+ // whether anything is there: `Stat` follows it and reports the target as
331
+ // absent, while `Lstat` finds the link itself. Told to create a directory over
332
+ // one, the author gets `EEXIST` and reads the same sentence again, which is
333
+ // the repair this predicate exists to avoid.
334
+ //
335
+ // Only an absent answer is asked the second question. A loop, a parent that
336
+ // denies traversal, a name the filesystem refuses, and a path too long each
337
+ // already say something the rule cannot improve on, and promoting one here
338
+ // would trade the operating system's own sentence for an assertion that a
339
+ // non-directory is in the way, which is the guess the third state exists to
340
+ // refuse. The second call is made only on the absent path, so an ordinary
341
+ // root still costs one stat.
342
+ //
343
+ // `fs.ErrNotExist` is the whole of that test, and Windows answers it for more
344
+ // than an absent path: `ERROR_BAD_NETPATH` maps to it, so a UNC root whose
345
+ // server is unreachable reads as missing and is told to create a directory it
346
+ // cannot create. That is the second concession of the same shape as a path
347
+ // whose parent is a file, and it is left for the same reason: separating it
348
+ // means reading errno numbers per platform inside a file whose whole subject
349
+ // is that a path means the same thing on both.
350
+ occupied := err == nil
351
+ if errors.Is(err, fs.ErrNotExist) {
352
+ if _, linkErr := os.Lstat(base.Absolute); linkErr == nil {
353
+ occupied = true
354
+ }
355
+ }
356
+ return describeBaseDirectoryProblem(base, kind, occupied, err)
357
+ }
358
+
359
+ // describeBaseDirectoryProblem says what a declared root turned out to be, and
360
+ // what to do about it.
361
+ //
362
+ // Three states reach it, and each one owns a repair the other two cannot be
363
+ // given. `occupied` says something that is not a directory is at the path, and
364
+ // `cause` is the stat's error when it had one. They are not exclusive: a link
365
+ // with no target occupies the path and fails the stat at once, and it is
366
+ // `occupied` that decides the sentence, because what the author has to do is
367
+ // replace what is there. `cause` is read only where `occupied` is false, which
368
+ // is the one shape the caller never produces without an error.
369
+ //
370
+ // `occupied` means the repair has to name what is in the way: told to "add that
371
+ // directory" over a file, an author follows the instruction, watches it fail,
372
+ // and reads the same sentence again. An absent path is created. A stat that
373
+ // failed for any other reason is the state where the rule does not know which
374
+ // of those two it is looking at, and it must not guess, because "create that
375
+ // directory" over a directory an unreadable parent is hiding is the same
376
+ // unfollowable repair a second time.
377
+ //
378
+ // The third repair therefore names no cause of its own. A permission is only
379
+ // the likeliest reason a stat fails without saying the path is absent; a name
380
+ // the filesystem refuses to spell, a path too long, and a link loop arrive the
381
+ // same way, and "make that path reachable" would be a guess in each of them.
382
+ // The operating system already wrote the reason one clause earlier.
383
+ //
384
+ // `fs.ErrNotExist` is what separates the second from the third, and the split
385
+ // is not identical on both platforms. A path whose parent is a file answers
386
+ // `ENOTDIR` on POSIX and a not-found error on Windows, so POSIX reads the
387
+ // filesystem's own reason while Windows offers "create that directory" over a
388
+ // path `mkdir -p` cannot create. Both lead clauses are true; that one repair
389
+ // clause is not followable, and it is left because separating it needs a stat
390
+ // of every ancestor on a path this rule otherwise touches once. "Correct the
391
+ // 'root' property" is offered first in both, and it is the repair that works.
392
+ //
393
+ // The Markdown and Prisma messages may still lead with "could not read",
394
+ // because the walk each of those callers is about to run is the read the root
395
+ // exists to serve, and it is skipped only because the root is not there.
396
+ //
397
+ // Both spellings appear, and only while they differ. The declared one is the
398
+ // property the author has to edit, and the resolved one is where that property
399
+ // actually landed, which is the whole question the moment a root ascends out of
400
+ // the project. Restating a path the declaration already names would offer it as
401
+ // an explanation of itself, which is why the test is the difference and not
402
+ // whether the root was absolute: a UNC spelling on POSIX is absolute and still
403
+ // differs, because `filepath.Clean` collapses its leading slashes there while
404
+ // Windows keeps the volume.
405
+ //
406
+ // The clause about resolution goes with it, for the stronger reason that it is
407
+ // false there. `resolvePopulationBase` joins the project root into a relative
408
+ // declared root and into nothing else, so "it resolves against the ttsc project
409
+ // root" describes an act that did not happen whenever the root was absolute.
410
+ // `declaredRootIsAbsolute` is what both the resolution and this sentence read,
411
+ // so the sentence cannot outlive the act it reports.
412
+ //
413
+ // TypeScript is told apart in the verb and in the repair clause. Its root
414
+ // re-bases addressing over sources the Program already holds and never scans a
415
+ // directory, so a message about reading one would name an access this artifact
416
+ // kind never attempts.
417
+ //
418
+ // The verb also has to stay clear of the path sense beside it. "Resolves to"
419
+ // and "resolves against" are both composition, and a lead clause saying the
420
+ // root could not be resolved would make one sentence carry that sense and its
421
+ // opposite, in the one diagnostic whose whole job is to stop a misreading.
422
+ //
423
+ // Keep the two facts of the repair clause apart. The root spelling resolves
424
+ // against the project root; the sources re-base onto the root. Merging them
425
+ // into "re-bases Program sources against the ttsc project root" states the
426
+ // reverse of what the property does, and reads as though declaring it changed
427
+ // nothing, which is the one conclusion an author must not draw here.
428
+ //
429
+ // Every repair clause that ends in a directory takes two steps, and that part
430
+ // is not about TypeScript at all. This stat is satisfied by an empty directory,
431
+ // so creating one silences the diagnostic and leaves the population exactly as
432
+ // empty, and for a claim that is worse than the diagnostic was, because an
433
+ // empty healthy claim deactivates without a word. Those branches therefore ask
434
+ // for what the directory must hold, and the split is disk against Program
435
+ // rather than one noun against another: the walkers want the sources on disk,
436
+ // and TypeScript wants them in the Program.
437
+ func describeBaseDirectoryProblem(
438
+ base populationBase,
439
+ kind artifactKind,
440
+ occupied bool,
441
+ cause error,
442
+ ) string {
443
+ unexaminable := !occupied && !errors.Is(cause, fs.ErrNotExist)
444
+ label := populationRootLabel(base)
445
+ // Two questions, two tests, and they are not the same one. Restating the
446
+ // resolved path tells a reader something only where it differs from the label,
447
+ // which almost every absolute declared root fails and a UNC spelling on POSIX
448
+ // passes, because `filepath.Clean` collapses its leading slashes while the
449
+ // declaration keeps them. Whether the project root was composed into the
450
+ // spelling at all is a different question, it is what gates the clause that
451
+ // says so, and only `declaredRootIsAbsolute` answers it.
452
+ restate := label != filepath.ToSlash(base.Absolute)
453
+ resolved := !declaredRootIsAbsolute(base.Declared)
454
+ if unexaminable {
455
+ message := "Evidence graph could not examine the " + string(kind) + " root '" + label + "'"
456
+ if restate {
457
+ message += ", which resolves to '" + filepath.ToSlash(base.Absolute) + "'"
458
+ }
459
+ message += ": " + causeText(cause) + ". Correct the 'root' property, or clear the condition the filesystem reported"
460
+ if resolved {
461
+ message += "; it resolves against the ttsc project root"
462
+ }
463
+ if kind != artifactTypeScript {
464
+ return message + "."
465
+ }
466
+ return message + ". A " + string(kind) +
467
+ " root is checked by this stat alone: it re-bases Program sources onto itself rather than scanning the filesystem."
468
+ }
469
+ message := "Evidence graph could not read the " + string(kind) + " root '" + label + "'"
470
+ if kind == artifactTypeScript {
471
+ message = "Evidence graph found no directory at the " + string(kind) + " root '" + label + "'"
472
+ }
473
+ if restate {
474
+ message += ", which resolves to '" + filepath.ToSlash(base.Absolute) + "'"
475
+ }
476
+ if occupied {
477
+ message += ", because that path is not a directory"
478
+ }
479
+ message += ". Correct the 'root' property, or "
480
+ switch {
481
+ case kind == artifactTypeScript && occupied:
482
+ message += "replace that path with a directory and make its sources part of the tsconfig Program"
483
+ case kind == artifactTypeScript:
484
+ message += "add that directory and make its sources part of the tsconfig Program"
485
+ case occupied:
486
+ message += "replace that path with a directory and the " + string(kind) + " sources it should hold"
487
+ default:
488
+ message += "create that directory and the " + string(kind) + " sources it should hold"
489
+ }
490
+ message += "; "
491
+ if resolved {
492
+ message += "it resolves against the ttsc project root, and "
493
+ }
494
+ if kind == artifactTypeScript {
495
+ return message + "a " + string(kind) +
496
+ " root re-bases Program sources onto itself rather than scanning the filesystem."
497
+ }
498
+ return message + "an empty directory leaves the population just as empty."
499
+ }
500
+
501
+ // resolvedBaseDirectory is the directory a base's files sit in, and reports
502
+ // whether the links naming it end at one.
503
+ //
504
+ // A declared root may be a link, and `os.Stat` accepts one as a directory when
505
+ // the gate reads it, so a consumer that then compares paths against the base has
506
+ // to compare against what the link names. The filesystem never needs this: it
507
+ // opens a path through any link transparently. A comparison does, because the
508
+ // two sides are strings and only one of them was spelled by this rule.
509
+ //
510
+ // Every component is resolved, not only the leaf. A link on an ancestor is
511
+ // exactly what `os.Lstat` of the leaf cannot see, and it is the shape a package
512
+ // manager installs: the workspace dependency is the link and the root an author
513
+ // declares is a directory inside it. Resolving only the leaf left that silent,
514
+ // which is what #1269 recorded.
515
+ //
516
+ // The answer is verified rather than trusted, at every component and not only
517
+ // at the last. `resolveLinkedDirectory` gives up after a fixed number of hops
518
+ // and returns the link it stopped on, while the stat in `baseDirectoryProblem`
519
+ // follows further on Linux and on Windows, so a long enough chain passes the
520
+ // gate and leaves whoever trusted the resolver holding a link. Asking only the
521
+ // leaf would miss it on an ancestor for the same reason `os.Lstat` does:
522
+ // traversal through a link is transparent, so the remaining components still
523
+ // name a real directory and the last stat says so. Darwin and the BSDs stop at
524
+ // the same number of hops, so for a declared root the gate answers first and
525
+ // the refusal is unreachable there. The default base is not gated at all,
526
+ // because `baseDirectoryProblem` returns on it without stat'ing anything, so a
527
+ // walker reaches this refusal for the project root on every platform.
528
+ //
529
+ // A base that is not a link costs one `os.Lstat` per path component plus one
530
+ // for this answer, where resolving the leaf alone cost two. It is paid per call
531
+ // rather than per file, and the call count is the graph's own shape rather than
532
+ // this function's: claim-side populations are materialized before the whole
533
+ // configuration, so a base is asked once for each load it appears in. That is
534
+ // at most twice for a Markdown or Prisma claim base and once for its reference
535
+ // bases, and twice those numbers for TypeScript, whose gate asks beside the
536
+ // pass that builds its match table and whose governance pass builds one more
537
+ // from the configuration as declared. All of it stays off the loop this feeds,
538
+ // which is every source file of the project.
539
+ func resolvedBaseDirectory(base populationBase) (string, bool) {
540
+ from, resolved := resolveLinkedPath(base.Absolute)
541
+ if !resolved {
542
+ return from, false
543
+ }
544
+ info, err := os.Lstat(from)
545
+ return from, err == nil && info.IsDir()
546
+ }
547
+
548
+ // resolveLinkedPath resolves a link at any component of an absolute path.
549
+ //
550
+ // `filepath.EvalSymlinks` does this in one call and cannot be used, for the
551
+ // reason `resolveLinkedDirectory` exists at all: it returns a Windows junction
552
+ // unchanged, which is the link a package manager creates there. So the walk is
553
+ // by hand, and it asks the same resolver at every prefix.
554
+ //
555
+ // The volume is taken off first and never split. A Windows drive root and a UNC
556
+ // share are one component whose separator is part of them, and joining their
557
+ // pieces back would name a different location, which is the same hazard
558
+ // `normalizeRootPath` hand-cleans for a declared spelling. A base that is the
559
+ // share itself has nothing left to walk, and it returns unchanged rather than
560
+ // gaining the separator this walk starts from: a spelling that differs from
561
+ // the base by one character resolves to the same directory and compares as
562
+ // though it did not, so every consumer would pay the second spelling forever
563
+ // on a path with no link in it.
564
+ //
565
+ // A component whose chain outran the resolver ends the walk. Joining the rest
566
+ // onto it would produce a path the filesystem opens and every string
567
+ // comparison misses, which is #1269 exactly, one component further up than the
568
+ // case that opened it.
569
+ func resolveLinkedPath(absolute string) (string, bool) {
570
+ volume := filepath.VolumeName(absolute)
571
+ rest := absolute[len(volume):]
572
+ current := volume + string(filepath.Separator)
573
+ walked := false
574
+ for _, segment := range strings.Split(filepath.ToSlash(rest), "/") {
575
+ if segment == "" {
576
+ continue
577
+ }
578
+ walked = true
579
+ resolved, settled := resolveLinkedDirectory(filepath.Join(current, segment))
580
+ if !settled {
581
+ return filepath.FromSlash(resolved), false
582
+ }
583
+ current = resolved
584
+ }
585
+ if !walked {
586
+ return filepath.Clean(absolute), true
587
+ }
588
+ return filepath.FromSlash(current), true
589
+ }
590
+
591
+ // unresolvedBaseProblem reports a base whose links this rule stops following
592
+ // before they reach a directory.
593
+ //
594
+ // The chain may be the root itself or any directory above it, so the sentence
595
+ // says the path passes through one rather than that it is one. Naming the
596
+ // component would be more precise and is deliberately not done: this message
597
+ // already carries the declared spelling and, where they differ, the path it
598
+ // resolves to, and a third path would cost more to read than the component
599
+ // buys — the repair is the same wherever on the path the chain sits.
600
+ //
601
+ // Every artifact kind gets this sentence, because the failure is the resolver's
602
+ // bound rather than anything a walk or a Program does, and the kind appears only
603
+ // as the noun. The verb stays clear of "resolve" for the reason
604
+ // `describeBaseDirectoryProblem` records: that word is composition everywhere
605
+ // else in these messages, and a lead clause borrowing it for failure would make
606
+ // one sentence carry both senses.
607
+ //
608
+ // The default base is named and repaired differently, because it declared no
609
+ // `root` and telling its author to correct that property sends them looking for
610
+ // a line their configuration does not contain. It is the ttsc project root, so
611
+ // it is named as one and the repair is the invocation.
612
+ func unresolvedBaseProblem(base populationBase, kind artifactKind) string {
613
+ label := populationRootLabel(base)
614
+ message := "Evidence graph found no directory at the end of the "
615
+ if base.Default {
616
+ message += "ttsc project root '" + label + "'"
617
+ } else {
618
+ message += string(kind) + " root '" + label + "'"
619
+ }
620
+ // The resolved path is restated only when it differs from the label, which is
621
+ // the whole question: an absolute declared root and the default base both name
622
+ // the path they landed on, while a UNC spelling on POSIX does not, because
623
+ // `filepath.Clean` collapses its leading slashes and the two genuinely differ.
624
+ // `describeBaseDirectoryProblem` makes the same test for the same reason.
625
+ if resolved := filepath.ToSlash(base.Absolute); label != resolved {
626
+ message += ", which resolves to '" + resolved + "'"
627
+ }
628
+ message += ". That path passes through a chain of links longer than this rule follows. "
629
+ if base.Default {
630
+ return message + "Run ttsc against the directory those links end at."
631
+ }
632
+ return message + "Correct the 'root' property to name the directory those links end at."
633
+ }
634
+
635
+ // causeText spells a reason for a sentence that owns its own terminator.
636
+ //
637
+ // Windows ends its messages with a period and POSIX does not, so a rule that
638
+ // appends one prints "denied.. Correct the" on the one platform and "denied.
639
+ // Correct the" on the other, over the same failure. The terminator belongs to
640
+ // the sentence this rule writes, and the reason belongs to whoever wrote it, so
641
+ // only the punctuation is taken.
642
+ //
643
+ // The reason may come from the filesystem, a subprocess, or this rule's own
644
+ // inner validation. Only the trim happens here; the sentence supplies its
645
+ // terminator, which is what lets a quoted glob error that writes none end with
646
+ // one instead of ending bare.
647
+ func causeText(cause error) string {
648
+ return causeReason(cause.Error())
649
+ }
650
+
651
+ // causeReason is the same rule for a failure already reduced to text. A package
652
+ // entry read, a package walk, and a package source read take a string from the
653
+ // loader, and the Prisma and Swagger bridges hand back a parser's own sentence,
654
+ // so those five sites reach the rule this way rather than through an error.
655
+ func causeReason(text string) string {
656
+ return strings.TrimSuffix(text, ".")
657
+ }
658
+
659
+ // unlistableBaseProblem reports a population whose own base could not be
660
+ // listed.
661
+ //
662
+ // This is a different finding from the entry-level one below, and the
663
+ // difference is the whole population. An entry the walk could not read costs
664
+ // the units in it; a base the walk could not list costs every unit there is,
665
+ // and reporting it through the per-entry line would name the base as though it
666
+ // were one file inside itself. Both walkers reach it through the error their
667
+ // callback returns for that one path, which is what ends the walk and brings
668
+ // the failure back here.
669
+ //
670
+ // The base is named by its configuration spelling rather than by a location,
671
+ // because that is the property an author edits, and because a base with no
672
+ // declared root has no other name than where it is.
673
+ //
674
+ // One cause reaches this: a listing the filesystem refused. A chain of links the
675
+ // resolver stops following used to arrive here as well and has its own sentence
676
+ // now, because that failure is the resolver's bound and this repair is about
677
+ // access.
678
+ func unlistableBaseProblem(
679
+ base populationBase,
680
+ sources string,
681
+ cause error,
682
+ ) string {
683
+ return "Evidence graph could not walk " + sources + " root '" + populationRootLabel(base) +
684
+ "': " + causeText(cause) + ". Make that root a directory this process can list, so its configured " +
685
+ sources + " sources can be indexed."
686
+ }
687
+
688
+ // unreadableEntryProblem decides whether a walk failure inside a population
689
+ // belongs to it, and spells it if it does.
690
+ //
691
+ // The Markdown and Prisma walkers share it because they are otherwise one
692
+ // decision written twice, and its two halves fail differently. Reporting a path
693
+ // no configured glob reaches turns an unrelated permission on an unrelated
694
+ // directory into a build error; printing the path the walker was handed spells
695
+ // it the way the filesystem API does rather than the way the rest of the loader
696
+ // spells every path beside it.
697
+ //
698
+ // `reads` is the population's own membership question, which is the only part
699
+ // that differs by artifact kind. `from` is the directory the walk started at,
700
+ // which is the base unless the base is a link, and it is what a callback path
701
+ // is measured against; the spelling still comes from the base, so a reader sees
702
+ // the root they declared either way. The base itself never arrives here: it is
703
+ // not one entry among many, and each walker answers it before this is asked.
704
+ func unreadableEntryProblem(
705
+ base populationBase,
706
+ from string,
707
+ sources string,
708
+ current string,
709
+ cause error,
710
+ reads func(relative string) bool,
711
+ ) (string, bool) {
712
+ relative, ok := relativeProjectPath(from, current)
713
+ if !ok || !reads(relative) {
714
+ return "", false
715
+ }
716
+ return unreadableWalkEntryProblem(base, relative, sources, cause), true
717
+ }
718
+
719
+ // unreadableWalkEntryProblem names one path a population walk could not
720
+ // inspect.
721
+ //
722
+ // The path a `filepath.WalkDir` callback receives is the OS-native absolute
723
+ // one, and printing it as it arrives made this the only line in either walker
724
+ // spelled with backslashes on Windows, against globs, a root, and file
725
+ // locations that all pass through `filepath.ToSlash`. A reader compares them
726
+ // against each other, so the walker composes the base-relative path it already
727
+ // holds through the same `display` a loaded file's own message uses.
728
+ //
729
+ // The cause is passed through as the filesystem wrote it. It may embed an
730
+ // OS-native absolute path of its own, and rewriting a sentence this rule did
731
+ // not author is a different claim than spelling its own paths one way.
732
+ func unreadableWalkEntryProblem(
733
+ base populationBase,
734
+ relative string,
735
+ sources string,
736
+ cause error,
737
+ ) string {
738
+ return "Evidence graph could not inspect '" + base.display(relative) +
739
+ "': " + causeText(cause) + ". Fix filesystem access so configured " + sources +
740
+ " sources can be indexed."
246
741
  }
247
742
 
248
743
  // normalizeRootPath validates a declared root without resolving it.