@abide/abide 0.123.2 → 0.123.3

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 (94) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/bin/abide.ts +36 -65
  3. package/package.json +1 -1
  4. package/src/compiler/Diagnostics.ts +198 -0
  5. package/src/compiler/KeptChecker.ts +97 -0
  6. package/src/compiler/Lowering.ts +1 -1
  7. package/src/compiler/PositionedRefusal.ts +1 -1
  8. package/src/compiler/TypeSession.ts +62 -85
  9. package/src/compiler/appLayout.ts +28 -20
  10. package/src/compiler/argsDefaultsIn.ts +1 -1
  11. package/src/compiler/checkApp.ts +64 -68
  12. package/src/compiler/checkedShadow.ts +1 -1
  13. package/src/compiler/compileApp.ts +101 -70
  14. package/src/compiler/declaredExports.ts +2 -4
  15. package/src/compiler/emitClient.ts +1 -1
  16. package/src/compiler/formatAbide.ts +2 -2
  17. package/src/compiler/handlersUnder.ts +17 -29
  18. package/src/compiler/lineOf.ts +0 -19
  19. package/src/compiler/matchersOf.ts +3 -3
  20. package/src/compiler/mountsOf.ts +3 -3
  21. package/src/compiler/readSources.ts +16 -18
  22. package/src/compiler/readsIn.ts +1 -1
  23. package/src/compiler/serveLanguage.ts +23 -57
  24. package/src/compiler/sharedModuleWrites.ts +1 -1
  25. package/src/compiler/templateWarnings.ts +1 -2
  26. package/src/compiler/trackingWarnings.ts +1 -1
  27. package/src/server/CLIENT_BUILD.ts +4 -32
  28. package/src/server/RESUME_MILLISECONDS.ts +2 -0
  29. package/src/server/answerRequest.ts +11 -300
  30. package/src/server/answerRpc.ts +12 -54
  31. package/src/server/answerSocket.ts +8 -35
  32. package/src/server/appBuild.ts +377 -0
  33. package/src/server/appPlugins.ts +15 -17
  34. package/src/server/appTree.ts +6 -5
  35. package/src/server/argsContract.ts +209 -0
  36. package/src/server/bridgeRelay.ts +153 -65
  37. package/src/server/claudeCode.ts +30 -127
  38. package/src/server/config.ts +6 -7
  39. package/src/server/createApp.ts +30 -64
  40. package/src/server/devWorker.ts +11 -15
  41. package/src/server/handlerFileMount.ts +40 -0
  42. package/src/server/index.ts +2 -2
  43. package/src/server/packageDirectory.ts +14 -0
  44. package/src/server/preload.ts +3 -2
  45. package/src/server/principal.ts +4 -3
  46. package/src/server/remote.ts +1 -1
  47. package/src/server/runCompiled.ts +1 -1
  48. package/src/server/runDesktop.ts +1 -1
  49. package/src/server/seedBuffer.ts +1 -1
  50. package/src/server/serveDev.ts +24 -58
  51. package/src/server/servedApp.ts +390 -0
  52. package/src/server/signInChanged.ts +20 -0
  53. package/src/server/surfaceOf.ts +2 -1
  54. package/src/server/versionOf.ts +6 -15
  55. package/src/shared/applyPatch.ts +1 -1
  56. package/src/shared/muxFrames.ts +1 -1
  57. package/src/shared/reactive/Keyed.ts +1 -1
  58. package/src/shared/reactive/Reactive.ts +21 -8
  59. package/src/shared/reactive/argsContract.ts +131 -4
  60. package/src/shared/reactive/index.ts +1 -0
  61. package/src/shared/reactive/patchLog.ts +79 -0
  62. package/src/shared/reactive/patchMarks.ts +34 -0
  63. package/src/shared/reactive/recordWrites.ts +11 -14
  64. package/src/shared/reactive/share.ts +4 -3
  65. package/src/shared/rpc.ts +12 -19
  66. package/src/ui/ArmRegion.ts +10 -4
  67. package/src/ui/Level.ts +11 -20
  68. package/src/ui/LevelChain.ts +272 -0
  69. package/src/ui/Router.ts +54 -245
  70. package/src/ui/Scope.ts +17 -3
  71. package/src/ui/TryRegion.ts +22 -23
  72. package/src/ui/bindSpread.ts +2 -4
  73. package/src/ui/listenForRoutes.ts +3 -10
  74. package/src/ui/outboxQueue.ts +21 -3
  75. package/src/ui/reconcile.ts +4 -6
  76. package/src/ui/routing.ts +2 -1
  77. package/src/ui/rows.ts +2 -2
  78. package/src/ui/setAttribute.ts +12 -3
  79. package/src/ui/socketWire.ts +26 -11
  80. package/src/compiler/Finding.ts +0 -2
  81. package/src/compiler/Refusals.ts +0 -65
  82. package/src/compiler/formatDiagnosis.ts +0 -20
  83. package/src/server/appDirectories.ts +0 -9
  84. package/src/server/argsFor.ts +0 -39
  85. package/src/server/buildDirectory.ts +0 -10
  86. package/src/server/coerceArgs.ts +0 -109
  87. package/src/server/derivedArgs.ts +0 -26
  88. package/src/server/runBuild.ts +0 -24
  89. package/src/server/serveBuild.ts +0 -144
  90. package/src/server/testBuild.ts +0 -220
  91. package/src/shared/reactive/FOLLOWED.ts +0 -8
  92. package/src/shared/reactive/PATCHES.ts +0 -11
  93. package/src/shared/reactive/PATCH_SINKS.ts +0 -26
  94. package/src/shared/reactive/canonicalKey.ts +0 -95
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @abide/abide
2
2
 
3
+ ## 0.123.3
4
+
5
+ ### Patch Changes
6
+
7
+ - abfff21: Router's doc names the LevelChain that stops a page's scope ([`00c3d2d`](https://github.com/briancray/abide/commit/00c3d2d8038c673e5a067b01402b0d270fa485b8))
8
+ - abfff21: a spread writes through setAttribute's compare-then-write (writeAttribute) ([`017a6ff`](https://github.com/briancray/abide/commit/017a6ff26a1e9ba460973b63b2c8ffc5f9e2f863))
9
+ - abfff21: the routed mount argument comment names LevelChain.mountFrom ([`0942e6e`](https://github.com/briancray/abide/commit/0942e6e9d6545dd24d41579fd5ec28e9478892d9))
10
+ - abfff21: a patch-log release run twice leaves a log a later follower opened ([`206d8df`](https://github.com/briancray/abide/commit/206d8dfde626592eb5ca885567f30afeed6b0b6e))
11
+ - abfff21: one patch log for followed values, and patch marks behind functions ([`3cdda11`](https://github.com/briancray/abide/commit/3cdda118d9a8ed4cbd32834c8cd7823c26f0b5fb))
12
+ - abfff21: one args contract for every way a handler is called ([`4e2dcab`](https://github.com/briancray/abide/commit/4e2dcab0bddaf555404fc22687e2efd41d28b500))
13
+ - abfff21: the {#try} ambient is Scope's private, set only through underBoundary; TryRegion swaps through ArmRegion's fills/armOf ([`54e6a91`](https://github.com/briancray/abide/commit/54e6a9158bd9fe167578479a3ce4669d13797457))
14
+ - abfff21: a file the layout has not seen is checked as painting only where it paints ([`587597f`](https://github.com/briancray/abide/commit/587597f23cdeb331816049ff0670b99eebcb1cd8))
15
+ - abfff21: one diagnostics sink owns the finding type, its placement, abide-ignore, order and printing for build, dev, check, format and the language server ([`6d11f57`](https://github.com/briancray/abide/commit/6d11f57adc226b45bd0ddf1b4a305ee6d7a7a000))
16
+ - abfff21: socketWire and outboxQueue each reset in one call (resetSocketWire, resetOutbox); tests stop reaching into Mux internals and draining the outbox by timing ([`721774f`](https://github.com/briancray/abide/commit/721774f93df6040e20015f03755879c4cf534a38))
17
+ - abfff21: a mounted handler is held to the schema its row derived, so a bundled executable coerces and validates its args (RULEBOOK 19.9) ([`76c92a5`](https://github.com/briancray/abide/commit/76c92a51f0b3f49ee95e95d8088c320d7a896341))
18
+ - abfff21: an editor shows the findings abide check has for a file — lints, handler warnings and the emit's refusals included (RULEBOOK 38.11) ([`9824535`](https://github.com/briancray/abide/commit/9824535e191b0f9748edaafadc0bc8ecc08f75d0))
19
+ - abfff21: simplify pass — one placedSource, packageDirectory in appTree, derivedArgs probes once, manifest read beside the table, PatchLog followers always a set, pairingStatus yields the code alone, KeptChecker's heard takes nothing ([`9a6e663`](https://github.com/briancray/abide/commit/9a6e66361364b841cb53362ace080eb1e7d0372f))
20
+ - abfff21: ServerRoute and ServerError stay private to answerRequest ([`9c51de6`](https://github.com/briancray/abide/commit/9c51de674db98e4c2793fa8f496ebe54924c087f))
21
+ - abfff21: the build on disk is one module, and booting from a compilation is one ordering ([`a3a5349`](https://github.com/briancray/abide/commit/a3a5349baa9c55c9a931f00d635a57743ddf1cdc))
22
+
23
+ An app with no `src/` — its root is its package — now keeps its default `.abide` build and its `bun test` build cache inside the package rather than in the directory above it, as RULEBOOK 38.27 has a build sit beside its app; an app with a `src/` is unchanged.
24
+ - abfff21: a LevelChain owns the routed levels and their head bracketing; Router takes a popped history step through one entry and keeps its step bookkeeping private ([`a4d9641`](https://github.com/briancray/abide/commit/a4d9641105f9d1f9958b9bb6fcf90323c07cd6a8))
25
+ - abfff21: a kept checker reopens a stale project, drops one that threw and owns its watchers, for dev and the language server alike ([`c733d7e`](https://github.com/briancray/abide/commit/c733d7ea7b648a02ed366ffe172aa3b996c7e0de))
26
+ - abfff21: a handler file's mount row is built once, for the compiler and the in-memory test app ([`c7db045`](https://github.com/briancray/abide/commit/c7db0459e89ba4ea74918f29d7823c87a9e01d47))
27
+ - abfff21: format the try and wire changes ([`c85913a`](https://github.com/briancray/abide/commit/c85913a0a36bc8e828885b795ba31340057a22ff))
28
+ - abfff21: the bridge relay writes its own frames and hears sign-ins rather than being told ([`ed69e87`](https://github.com/briancray/abide/commit/ed69e87be6e92f9980cb4f9b66edc59f56f7a88f))
29
+
3
30
  ## 0.123.2
4
31
 
5
32
  ### Patch Changes
package/bin/abide.ts CHANGED
@@ -16,8 +16,8 @@ import {
16
16
  type CompiledApp,
17
17
  } from '#compiler/compileApp.ts'
18
18
  import { compiledEntry } from '#compiler/compiledEntry.ts'
19
+ import { type Diagnosis, printDiagnoses } from '#compiler/Diagnostics.ts'
19
20
  import { formatAbide } from '#compiler/formatAbide.ts'
20
- import { formatDiagnosis } from '#compiler/formatDiagnosis.ts'
21
21
  import { parseAbide } from '#compiler/parseAbide.ts'
22
22
  import { scaffold } from '#compiler/scaffold.ts'
23
23
  import { serveLanguage } from '#compiler/serveLanguage.ts'
@@ -26,15 +26,17 @@ import {
26
26
  type Compilation,
27
27
  serverMapsOf,
28
28
  } from '#server/abideModules.ts'
29
- import { banner } from '#server/banner.ts'
30
- import { buildDirectory } from '#server/buildDirectory.ts'
31
29
  import {
32
- CLIENT_DIRECTORY,
33
- COMPILATION,
34
- ENCODING_SUFFIX,
35
- MANIFEST,
36
- PUBLIC_DIRECTORY,
37
- } from '#server/CLIENT_BUILD.ts'
30
+ buildDirectory,
31
+ type BuildFiles,
32
+ buildFiles,
33
+ builtFiles,
34
+ markBuild,
35
+ unmarkedBuild,
36
+ writeWhole,
37
+ } from '#server/appBuild.ts'
38
+ import { banner } from '#server/banner.ts'
39
+ import { ENCODING_SUFFIX } from '#server/CLIENT_BUILD.ts'
38
40
  import { type ClientBundle, clientBundle } from '#server/clientBundle.ts'
39
41
  import { type Given, type Host, runCommands } from '#server/commands.ts'
40
42
  import { type AppPackage, packageUnder } from '#server/config.ts'
@@ -45,7 +47,6 @@ import { namesUnder } from '#server/namesUnder.ts'
45
47
  import { skipped } from '#server/placement.ts'
46
48
  import { DEFAULT_DOCUMENT, intoHead } from '#server/render.ts'
47
49
  import { runtimeRefusal } from '#server/runtimeRefusal.ts'
48
- import { pathsUnder, serveBuild } from '#server/serveBuild.ts'
49
50
  import { serveDev } from '#server/serveDev.ts'
50
51
  import { escapeText } from '#shared/escapeText.ts'
51
52
  import { ICON_ADDRESSES, LAUNCHER_PAGE } from '#shared/LAUNCHER_FILES.ts'
@@ -72,12 +73,8 @@ const DISK: Host = {
72
73
  // 38.100 — built in this process rather than a child, where nobody built it, and said on
73
74
  // stderr: the command's stdout is its own — `openapi`'s document, `mcp`'s messages.
74
75
  app: ({ values }) => {
75
- const handle = appOnDisk(directoriesOf(values), async (root, out) =>
76
- (await serveBuild(root, out, (root, out) =>
77
- writeBuild({ root, out }, console.error),
78
- ))
79
- ? out
80
- : undefined,
76
+ const handle = appOnDisk(directoriesOf(values), (root, out) =>
77
+ writeBuild({ root, out }, console.error),
81
78
  )
82
79
  return RUNTIME_REFUSAL === undefined
83
80
  ? handle
@@ -116,15 +113,12 @@ async function writeBuild(
116
113
  process.env.NODE_ENV ??= 'production'
117
114
  // 38.66 — what this build may remove is only what was written before it began.
118
115
  const began = Date.now()
119
- // 38.100 — a build that fails leaves no build: the previous one's marker goes before anything
120
- // can fail, so `abide start` builds again and says why rather than booting what is left.
121
- const marker = `${out}/${COMPILATION}`
122
- await rm(marker, { force: true })
116
+ // 38.100 — before anything can fail, so a build that fails leaves no build.
117
+ const files = await unmarkedBuild(out)
123
118
  // `--out` is a real flag rather than a constant because two test files each building into
124
119
  // one `.abide/` is the contention CLAUDE.md's "checks" section already records once.
125
120
  const app = await compileApp(root)
126
- for (const warning of app.warnings)
127
- console.warn(formatDiagnosis(warning, process.cwd()))
121
+ printDiagnoses(app.warnings, console.warn)
128
122
  if (
129
123
  Object.keys(app.files).length === 0 &&
130
124
  Object.keys(app.handlers).length === 0
@@ -138,14 +132,13 @@ async function writeBuild(
138
132
  // 36.11 — the directories above `public` and the path below it ARE the address.
139
133
  // `src/public/favicon.ico` answers at `/favicon.ico`, and nothing declares that anywhere.
140
134
  await writeClient(
141
- out,
135
+ files,
142
136
  await clientBundle(root, compilation, app.published, 'build'),
143
137
  began,
144
138
  )
145
139
  // An app's static files are its own whether or not it has a page yet.
146
- const copied = await copyPublic(root, out, app.published, began)
147
- // LAST, because its presence is what `serveBuild` reads as a finished build (38.100).
148
- await writeWhole(marker, JSON.stringify(compilation))
140
+ const copied = await copyPublic(root, files, app.published, began)
141
+ await markBuild(files, compilation)
149
142
  say(
150
143
  `abide build: ${app.sources} source(s), ${copied} public file(s) -> ${out}`,
151
144
  )
@@ -299,9 +292,7 @@ async function writtenEntry(
299
292
  root,
300
293
  sourceMaps: serverMapsOf(app, root),
301
294
  package: packageUnder(root),
302
- manifest: await Bun.file(`${out}/${MANIFEST}`).json(),
303
- client: pathsUnder(`${out}/${CLIENT_DIRECTORY}`),
304
- published: pathsUnder(`${out}/${PUBLIC_DIRECTORY}`),
295
+ ...(await builtFiles(out)),
305
296
  }),
306
297
  )
307
298
  return { entry, compilation: app }
@@ -443,7 +434,7 @@ async function writeIcon(root: string, resources: string): Promise<boolean> {
443
434
 
444
435
  /** 38.79 — the public file the app's icon is drawn from: `/icon.icns`, else `/icon.png`. */
445
436
  async function publishedIcon(root: string): Promise<string | undefined> {
446
- const published = `${buildDirectory(root)}/${PUBLIC_DIRECTORY}`
437
+ const { published } = buildFiles(buildDirectory(root))
447
438
  for (const address of [ICON_ADDRESSES.icns, ICON_ADDRESSES.png])
448
439
  if (await Bun.file(`${published}${address}`).exists())
449
440
  return `${published}${address}`
@@ -517,7 +508,7 @@ ${body}</dict>
517
508
  */
518
509
  async function copyPublic(
519
510
  root: string,
520
- out: string,
511
+ files: BuildFiles,
521
512
  published: Map<string, string>,
522
513
  began: number,
523
514
  ): Promise<number> {
@@ -528,35 +519,35 @@ async function copyPublic(
528
519
  produced.add(address.slice(1))
529
520
  copies.push(
530
521
  writeWhole(
531
- `${out}/${PUBLIC_DIRECTORY}${address}`,
522
+ `${files.published}${address}`,
532
523
  Bun.file(`${root}/${source}`),
533
524
  ),
534
525
  )
535
526
  }
536
527
  await Promise.all(copies)
537
528
  // 38.66 — a public file the app no longer has is no longer answered.
538
- await pruneStale(`${out}/${PUBLIC_DIRECTORY}`, produced, began)
529
+ await pruneStale(files.published, produced, began)
539
530
  return published.size
540
531
  }
541
532
 
542
533
  /**
543
- * Writes the client bundle (`clientBundle`) into `out`, with its manifest and precompressed copies.
534
+ * Writes the client bundle (`clientBundle`) into the build, with its manifest and precompressed copies.
544
535
  *
545
536
  * 38.5 — the manifest beside the bundle, and every file in each encoding (`precompressed`). AN APP
546
537
  * OF HANDLERS ALONE is written no client, and its manifest anyway, so `abide start` reads one shape
547
538
  * whatever the app holds.
548
539
  */
549
540
  async function writeClient(
550
- out: string,
541
+ files: BuildFiles,
551
542
  bundle: ClientBundle,
552
543
  began: number,
553
544
  ): Promise<void> {
554
- const client = `${out}/${CLIENT_DIRECTORY}`
545
+ const { client } = files
555
546
  // 38.66 — STAGED, THEN MOVED IN FILE BY FILE. A second build into the same `out` — or `abide
556
547
  // start` serving it — read a chunk half written, and emptying `client/` first took the one it
557
548
  // was serving away. Every name but the manifest's is a hash of what it holds, so two builds of
558
549
  // one tree move in the same bytes under the same names.
559
- const staging = `${out}/.staging-${crypto.randomUUID()}`
550
+ const staging = `${dirname(client)}/.staging-${crypto.randomUUID()}`
560
551
  try {
561
552
  const written: Promise<void>[] = []
562
553
  for (const [name, { bytes }] of bundle.files)
@@ -574,7 +565,7 @@ async function writeClient(
574
565
  }
575
566
  await Promise.all(moves)
576
567
  await pruneStale(client, produced, began)
577
- await writeWhole(`${out}/${MANIFEST}`, JSON.stringify(bundle.manifest))
568
+ await writeWhole(files.manifest, JSON.stringify(bundle.manifest))
578
569
  } finally {
579
570
  await rm(staging, { recursive: true, force: true })
580
571
  }
@@ -642,17 +633,6 @@ async function pruneStale(
642
633
  await Promise.all(stale)
643
634
  }
644
635
 
645
- /**
646
- * Writes `path` whole: to a name of its own beside it, then renamed over it.
647
- *
648
- * 38.66 — a reader, or a second build, finds the last complete file there and never a partial one.
649
- */
650
- async function writeWhole(path: string, content: string | Blob): Promise<void> {
651
- const partial = `${path}.${crypto.randomUUID()}.partial`
652
- await Bun.write(partial, content)
653
- await rename(partial, path)
654
- }
655
-
656
636
  /**
657
637
  * Wraps a command body with the app's absolute root and build directory, or fails with no app.
658
638
  *
@@ -741,24 +721,18 @@ async function check(
741
721
  { positionals }: Given,
742
722
  ): Promise<number> {
743
723
  const within = namedDirectories(positionals)
744
- const found = await checkApp(root)
745
- let errors = 0
746
- let warnings = 0
747
- for (const diagnosis of found) {
724
+ const found: Diagnosis[] = []
725
+ for (const diagnosis of await checkApp(root))
748
726
  if (
749
- within.length > 0 &&
750
- !within.some(
727
+ within.length === 0 ||
728
+ within.some(
751
729
  (directory) =>
752
730
  diagnosis.path === directory ||
753
731
  diagnosis.path.startsWith(`${directory}/`),
754
732
  )
755
733
  )
756
- continue
757
- if (diagnosis.category === 'error') errors += 1
758
- else if (diagnosis.category === 'warning') warnings += 1
759
- else continue
760
- console.log(formatDiagnosis(diagnosis, process.cwd()))
761
- }
734
+ found.push(diagnosis)
735
+ const { errors, warnings } = printDiagnoses(found, console.log)
762
736
  console.log(
763
737
  `abide check: ${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}`,
764
738
  )
@@ -802,10 +776,7 @@ async function format(within: string[], { values }: Given): Promise<number> {
802
776
  next += 1
803
777
  const text = await Bun.file(path).text()
804
778
  const formatted = await formatAbide(path, text)
805
- for (const finding of formatted.findings) {
806
- errors += 1
807
- console.log(formatDiagnosis(finding, process.cwd()))
808
- }
779
+ errors += printDiagnoses(formatted.findings, console.log).errors
809
780
  if (formatted.text === text) continue
810
781
  changed += 1
811
782
  if (values.check)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.123.2",
3
+ "version": "0.123.3",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -0,0 +1,198 @@
1
+ import { relative, resolve } from 'node:path' // bun exposes no path helpers of its own
2
+
3
+ import { isIgnored } from './isIgnored.ts'
4
+ import { lineOf } from './lineOf.ts'
5
+ import { PositionedRefusal } from './PositionedRefusal.ts'
6
+
7
+ // EVERYTHING THE COMPILER HAS TO SAY, IN ONE PLACE: what a finding is, where it lands on the line an
8
+ // author wrote, which an author silenced, the order the lot is reported in, and the one line each is
9
+ // printed as. `abide build`, `abide dev`, `abide check`, `abide format` and `abide lsp` all read
10
+ // findings off this, so none of them decides for itself what a refusal does or how a warning reads.
11
+
12
+ /**
13
+ * One thing the checker said, placed in the file the author wrote.
14
+ *
15
+ * 38.3 — line and column from 1.
16
+ */
17
+ export type Diagnosis = {
18
+ path: string
19
+ line: number
20
+ column: number
21
+ code: number
22
+ category: 'error' | 'warning' | 'suggestion' | 'message'
23
+ message: string
24
+ }
25
+
26
+ /** A warning or a refusal at a projection offset. */
27
+ export type Finding = { at: number; message: string }
28
+
29
+ /**
30
+ * Where everything a compile finds goes, each at the file it was found in.
31
+ *
32
+ * TWO MODES AND ONE CALL for a refusal: a build stops at one, so a throwing sink throws it; `abide
33
+ * check` reports every one at its file and goes on (38.33), so a collecting sink keeps each as a
34
+ * finding at the file's first line. A warning is kept either way — it does not stop a build — and so
35
+ * is what the checker says (`checked`). Every stage takes this and nothing else, so no stage decides
36
+ * for itself what a refusal does, and none spells a path its own way: a file is named relative to
37
+ * `root` or absolutely, and kept absolute.
38
+ */
39
+ export class Diagnostics {
40
+ /** Every refusal kept, in the order met; empty in a throwing sink. */
41
+ readonly refused: Diagnosis[] = []
42
+ /** Every warning the compiler raised, in the order met (14, 32.14, 11.30, 18.15). */
43
+ readonly warnings: Diagnosis[] = []
44
+ /** What the checker said of each file (38.3). */
45
+ readonly checked: Diagnosis[] = []
46
+ /** What a file below the root is named relative to. */
47
+ readonly root: string
48
+ /** Whether a refusal is kept and the compile goes on, rather than thrown. */
49
+ readonly collecting: boolean
50
+
51
+ constructor(root: string, collecting: boolean) {
52
+ this.root = root
53
+ this.collecting = collecting
54
+ }
55
+
56
+ /** Every finding, in the order reported: the refusals, the warnings, then the checker's. */
57
+ get findings(): Diagnosis[] {
58
+ return [...this.refused, ...this.warnings, ...this.checked]
59
+ }
60
+
61
+ /**
62
+ * Refuses at `file`, below the root or absolute: `thrown` is thrown, or kept. Handed the file's
63
+ * `text`, a `PositionedRefusal` is placed on its line and column, and thrown as that line.
64
+ */
65
+ at(file: string, thrown: unknown, text?: string): void {
66
+ const positioned =
67
+ text !== undefined && thrown instanceof PositionedRefusal
68
+ if (!this.collecting && !positioned) throw thrown
69
+ const diagnosis = diagnosisAt(
70
+ resolve(this.root, file),
71
+ positioned ? text : undefined,
72
+ positioned ? thrown.offset : 0,
73
+ 'error',
74
+ thrown instanceof Error ? thrown.message : String(thrown),
75
+ )
76
+ if (!this.collecting)
77
+ throw new Error(formatDiagnosis(diagnosis, process.cwd()))
78
+ this.refused.push(diagnosis)
79
+ }
80
+
81
+ /**
82
+ * What another stage kept, as this one takes it: every one kept, or every one thrown as one
83
+ * error, each at its file — a build that stops names all it found rather than a first one and a
84
+ * second run (11.37).
85
+ */
86
+ adopt(found: readonly Diagnosis[]): void {
87
+ if (found.length === 0) return
88
+ if (this.collecting) {
89
+ this.refused.push(...found)
90
+ return
91
+ }
92
+ const lines: string[] = []
93
+ for (const one of found) lines.push(formatDiagnosis(one, process.cwd()))
94
+ throw new Error(lines.join('\n'))
95
+ }
96
+
97
+ /** Keeps `found` as warnings: the build goes on. */
98
+ warn(found: readonly Diagnosis[]): void {
99
+ this.warnings.push(...found)
100
+ }
101
+
102
+ /** Keeps what the checker said. */
103
+ check(found: readonly Diagnosis[]): void {
104
+ this.checked.push(...found)
105
+ }
106
+ }
107
+
108
+ /** abide's own finding at `offset` into `text`, or at line 0 where the text was not handed in. */
109
+ export function diagnosisAt(
110
+ path: string,
111
+ text: string | undefined,
112
+ offset: number,
113
+ category: Diagnosis['category'],
114
+ message: string,
115
+ ): Diagnosis {
116
+ return {
117
+ path,
118
+ ...(text === undefined ? { line: 0, column: 0 } : lineOf(text, offset)),
119
+ code: 0,
120
+ category,
121
+ message,
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Each of `findings`, at an offset `place` maps into the file at `path` the author wrote, as a
127
+ * finding on its line — or on line 0 where the text was not handed in.
128
+ *
129
+ * 14.19 — `reads`: they are warnings on reactive reads, and one the line above silences with
130
+ * `abide-ignore` and its clause is left out. No other finding is silenced so.
131
+ */
132
+ export function placedFindings(
133
+ path: string,
134
+ text: string | undefined,
135
+ findings: readonly Finding[],
136
+ place: (at: number) => number,
137
+ category: Diagnosis['category'],
138
+ reads = false,
139
+ ): Diagnosis[] {
140
+ const placed: Diagnosis[] = []
141
+ for (const finding of findings) {
142
+ const offset = place(finding.at)
143
+ if (
144
+ reads &&
145
+ text !== undefined &&
146
+ isIgnored(text, offset, finding.message)
147
+ )
148
+ continue
149
+ placed.push(diagnosisAt(path, text, offset, category, finding.message))
150
+ }
151
+ return placed
152
+ }
153
+
154
+ /** Orders findings by file, then line, then column. */
155
+ export function byPlace(one: Diagnosis, two: Diagnosis): number {
156
+ return (
157
+ one.path.localeCompare(two.path) ||
158
+ one.line - two.line ||
159
+ one.column - two.column
160
+ )
161
+ }
162
+
163
+ /**
164
+ * One finding as one `tsc`-style line.
165
+ *
166
+ * ONE LINE A FINDING, in the shape `tsc` and every editor's problem matcher already read:
167
+ * `file:line:column - error TS2322: …`, the path relative to `from`. A finding with no line — a
168
+ * whole-file or whole-app refusal — names the file alone.
169
+ */
170
+ export function formatDiagnosis(diagnosis: Diagnosis, from: string): string {
171
+ const path = relative(from, diagnosis.path) || diagnosis.path
172
+ const where =
173
+ diagnosis.line === 0
174
+ ? path
175
+ : `${path}:${diagnosis.line}:${diagnosis.column}`
176
+ const code = diagnosis.code === 0 ? '' : ` TS${diagnosis.code}`
177
+ return `${where} - ${diagnosis.category}${code}: ${diagnosis.message}`
178
+ }
179
+
180
+ /**
181
+ * Writes each error and warning in `found` as its line, relative to where the command was run, and
182
+ * answers how many of each — what every command that reports findings prints. A suggestion or a
183
+ * message is neither, and is not written.
184
+ */
185
+ export function printDiagnoses(
186
+ found: readonly Diagnosis[],
187
+ write: (line: string) => void,
188
+ ): { errors: number; warnings: number } {
189
+ let errors = 0
190
+ let warnings = 0
191
+ for (const diagnosis of found) {
192
+ if (diagnosis.category === 'error') errors += 1
193
+ else if (diagnosis.category === 'warning') warnings += 1
194
+ else continue
195
+ write(formatDiagnosis(diagnosis, process.cwd()))
196
+ }
197
+ return { errors, warnings }
198
+ }
@@ -0,0 +1,97 @@
1
+ import { type FSWatcher, watch } from 'node:fs' // bun exposes no file watcher of its own
2
+
3
+ import { type AppChecker, openChecker } from './compileApp.ts'
4
+
5
+ /**
6
+ * An app's checker kept for as long as a server runs, opened when first asked for and told what its
7
+ * watchers see move.
8
+ *
9
+ * `abide dev` and `abide lsp` keep one each, because spawning a checker is most of what a compile
10
+ * costs. WHAT KEEPING ONE TAKES is here rather than in either: one whose project moved
11
+ * (`AppChecker.current`) is closed and another opened, and one a question threw from is dropped —
12
+ * the checker's own process dying is a throw too, and a session nobody can tell apart from a dead
13
+ * one is not one to keep. `heard` is told every move after the checker is, so what it schedules
14
+ * reads a checker that already knows.
15
+ */
16
+ export class KeptChecker {
17
+ readonly root: string
18
+ private readonly heard: () => void
19
+ private readonly watchers: FSWatcher[] = []
20
+ private readonly watched = new Set<string>()
21
+ private checker: Promise<AppChecker> | undefined
22
+ private readonly open: (root: string) => Promise<AppChecker>
23
+
24
+ constructor(
25
+ root: string,
26
+ heard: () => void,
27
+ open: (root: string) => Promise<AppChecker> = openChecker,
28
+ ) {
29
+ this.root = root
30
+ this.heard = heard
31
+ this.open = open
32
+ this.watch(root, true)
33
+ }
34
+
35
+ /** A checker on the project as it stands: the one kept where it still is, or a fresh one. */
36
+ async live(): Promise<AppChecker> {
37
+ const kept = this.checker
38
+ if (kept !== undefined) {
39
+ const checker = await kept.catch(() => undefined)
40
+ if (checker?.current()) return checker
41
+ if (this.checker === kept) await this.drop()
42
+ }
43
+ this.checker ??= this.open(this.root)
44
+ return this.checker
45
+ }
46
+
47
+ /** `question` asked of a live checker; a throw drops it, so the next question opens another. */
48
+ async run<Answer>(
49
+ question: (checker: AppChecker) => Promise<Answer>,
50
+ ): Promise<Answer> {
51
+ try {
52
+ return await question(await this.live())
53
+ } catch (thrown) {
54
+ await this.drop()
55
+ throw thrown
56
+ }
57
+ }
58
+
59
+ /** Watches `directory` too — a linked package's, outside the root — once for the session. */
60
+ watch(directory: string, recursive = false): void {
61
+ if (this.watched.has(directory)) return
62
+ this.watched.add(directory)
63
+ this.watchers.push(
64
+ watch(directory, { recursive }, (_event, filename) =>
65
+ this.saw(
66
+ filename === null ? undefined : `${directory}/${filename}`,
67
+ ),
68
+ ),
69
+ )
70
+ }
71
+
72
+ /** Records a move at `path`, or one nobody could place, then tells `heard`. */
73
+ saw(path: string | undefined): void {
74
+ void this.checker?.then(
75
+ (checker) => checker.saw(path),
76
+ () => {},
77
+ )
78
+ this.heard()
79
+ }
80
+
81
+ /** Stops watching and closes the checker. */
82
+ async close(): Promise<void> {
83
+ for (const watcher of this.watchers) watcher.close()
84
+ this.watchers.length = 0
85
+ await this.drop()
86
+ }
87
+
88
+ private async drop(): Promise<void> {
89
+ const dropped = this.checker
90
+ this.checker = undefined
91
+ try {
92
+ await (await dropped)?.close()
93
+ } catch {
94
+ // Already gone, which is what closing it was for.
95
+ }
96
+ }
97
+ }
@@ -3,7 +3,7 @@ import { SyntaxKind } from 'typescript/unstable/ast'
3
3
  import type { CheckerAnswer } from './CheckerAnswer.ts'
4
4
  import type { Read } from './classifyRead.ts'
5
5
  import { holdsSlot } from './classifyTemplate.ts'
6
- import type { Finding } from './Finding.ts'
6
+ import type { Finding } from './Diagnostics.ts'
7
7
  import { type AbideFile, scriptBodies } from './parseAbide.ts'
8
8
  import {
9
9
  type Origin,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * A refusal that knows WHERE in the file's text it stands, so a sink handed the text reports it on
3
- * the line and column the author typed rather than at the file (`Refusals.at`).
3
+ * the line and column the author typed rather than at the file (`Diagnostics.at`).
4
4
  */
5
5
  export class PositionedRefusal extends Error {
6
6
  /** The offset into the file's text the refusal stands at. */