@abide/abide 0.123.3 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/ABIDE.md +8 -5
  2. package/CHANGELOG.md +31 -0
  3. package/docs/REGISTRY.md +45 -12
  4. package/docs/RULEBOOK.md +24 -14
  5. package/docs/abide.md +999 -459
  6. package/package.json +1 -1
  7. package/src/compiler/ARRAY_MUTATORS.ts +20 -0
  8. package/src/compiler/LoweredFile.ts +609 -0
  9. package/src/compiler/Lowering.ts +17 -0
  10. package/src/compiler/SHARED_NAMES.ts +2 -0
  11. package/src/compiler/ShadowHost.ts +670 -0
  12. package/src/compiler/ShadowMap.ts +1 -1
  13. package/src/compiler/TypeSession.ts +71 -1715
  14. package/src/compiler/appLints.ts +109 -0
  15. package/src/compiler/checkedShadow.ts +1 -1
  16. package/src/compiler/classifyRead.ts +16 -1
  17. package/src/compiler/compileApp.ts +2 -2
  18. package/src/compiler/declaredExports.ts +44 -17
  19. package/src/compiler/diagnoseSources.ts +232 -0
  20. package/src/compiler/formatAbide.ts +5 -6
  21. package/src/compiler/handlersUnder.ts +16 -35
  22. package/src/compiler/reactiveReads.ts +235 -0
  23. package/src/compiler/reactiveTypes.ts +13 -0
  24. package/src/compiler/readsIn.ts +180 -81
  25. package/src/compiler/routesDeclaration.ts +46 -0
  26. package/src/compiler/scaffold.ts +7 -2
  27. package/src/compiler/serveLanguage.ts +26 -20
  28. package/src/compiler/splitImports.ts +2 -2
  29. package/src/server/DesktopSession.ts +213 -0
  30. package/src/server/Followers.ts +399 -0
  31. package/src/server/MODULE_QUERIES.ts +6 -2
  32. package/src/server/PageRender.ts +201 -0
  33. package/src/server/abideModules.ts +7 -7
  34. package/src/server/answerFailure.ts +3 -2
  35. package/src/server/answerRequest.ts +6 -70
  36. package/src/server/answerRpc.ts +48 -260
  37. package/src/server/answerSocket.ts +41 -463
  38. package/src/server/appKit.ts +1 -1
  39. package/src/server/argsContract.ts +1 -1
  40. package/src/server/createApp.ts +8 -2
  41. package/src/server/csp.ts +1 -1
  42. package/src/server/deferredBuilds.ts +3 -16
  43. package/src/server/faceOf.ts +1 -1
  44. package/src/server/generatedSurfaces.ts +2 -1
  45. package/src/server/handlerFileMount.ts +1 -1
  46. package/src/server/index.ts +1 -1
  47. package/src/server/installServer.ts +5 -9
  48. package/src/server/invokeHandler.ts +230 -0
  49. package/src/server/mountHandlers.ts +184 -0
  50. package/src/server/openDesktopWindow.ts +308 -0
  51. package/src/server/preload.ts +18 -0
  52. package/src/server/principal.ts +1 -1
  53. package/src/server/remote.ts +1 -1
  54. package/src/server/render.ts +31 -59
  55. package/src/server/requestScope.ts +5 -36
  56. package/src/server/runDesktop.ts +19 -447
  57. package/src/server/seedBuffer.ts +11 -11
  58. package/src/server/servedApp.ts +1 -1
  59. package/src/server/shell.ts +1 -1
  60. package/src/server/surfaceOf.ts +1 -1
  61. package/src/server/versionOf.ts +1 -1
  62. package/src/shared/RPC_SERVER.ts +27 -31
  63. package/src/shared/Schema.ts +1 -2
  64. package/src/shared/index.ts +1 -1
  65. package/src/shared/{note.ts → issueReport.ts} +22 -0
  66. package/src/shared/loadCall.ts +66 -0
  67. package/src/shared/reactive/Keyed.ts +23 -15
  68. package/src/shared/reactive/Reactive.ts +34 -1001
  69. package/src/shared/reactive/byOf.ts +2 -5
  70. package/src/shared/reactive/callLoad.ts +8 -14
  71. package/src/shared/reactive/cellShape.ts +734 -0
  72. package/src/shared/reactive/index.ts +12 -24
  73. package/src/shared/reactive/pairReactive.ts +5 -3
  74. package/src/shared/reactive/producer.ts +121 -0
  75. package/src/shared/reactive/scoped.ts +3 -2
  76. package/src/shared/reactive/selections.ts +209 -0
  77. package/src/shared/reactive/watch.ts +3 -4
  78. package/src/shared/rpc.ts +8 -5
  79. package/src/shared/validateJson.ts +1 -2
  80. package/src/shared/writePath.ts +29 -0
  81. package/src/ui/Router.ts +53 -69
  82. package/src/ui/outboxQueue.ts +2 -8
  83. package/src/ui/outcomeOf.ts +120 -0
  84. package/src/ui/socketWire.ts +3 -3
  85. package/src/ui/wire.ts +9 -10
  86. package/src/shared/LOAD_OFFLINE.ts +0 -15
  87. package/src/shared/LOAD_SIGNAL.ts +0 -10
  88. package/src/shared/issuesOf.ts +0 -18
  89. package/src/shared/reactive/ENTRY_LIVES.ts +0 -7
@@ -0,0 +1,670 @@
1
+ import { type Dirent, readdirSync, realpathSync, statSync } from 'node:fs' // Bun exposes no sync directory listing or realpath of its own
2
+ import { basename, dirname, join, resolve } from 'node:path' // Bun exposes no path helpers of its own
3
+
4
+ import type { SourceFile } from 'typescript/unstable/ast'
5
+ import { API, type Project, type Snapshot } from 'typescript/unstable/async'
6
+
7
+ import { placeOf } from '#server/placement.ts'
8
+
9
+ import { COMPONENT_MODULE } from './checkedShadow.ts'
10
+ import type { LoweredFile } from './LoweredFile.ts'
11
+ import { CHECKED_SUFFIX, SHADOW_SUFFIX, ShadowMap } from './ShadowMap.ts'
12
+ import { SOURCE_SUFFIX } from './splitImports.ts'
13
+
14
+ // Where every analysis of an app meets the checker. A `.abide` is projected into a SHADOW `.ts` held
15
+ // in a virtual filesystem and never written to disk, and TypeScript's own checker is asked about it.
16
+ // This holds those files, the snapshots the checker takes of them, and the lowerings an editor's
17
+ // questions map their offsets through — and `ask` is the one way a question reaches a project.
18
+ //
19
+ // The API is the ASYNC one on purpose. `typescript/unstable/sync` reads `child.stdout._handle.fd`
20
+ // and bun exposes no fd on a child pipe, so the sync channel cannot connect. The async channel is
21
+ // a stream and needs nothing.
22
+
23
+ /** The files a write registered and moved, for the next snapshot to be told. */
24
+ export type ShadowEdit = { created: string[]; changed: string[] }
25
+
26
+ /** The checker process, the virtual files it reads, and the snapshots it holds of them. */
27
+ export class ShadowHost {
28
+ private api: API
29
+ /** The `tsconfig` the host was opened on, which a file outside its project is refused by. */
30
+ readonly tsconfig: string
31
+ /**
32
+ * Per checker key, the text the checker reads for a file this host wrote, and the path it
33
+ * was registered under.
34
+ *
35
+ * KEYED AS THE CHECKER KEYS ITS OWN CACHE (`keyOf`), not by the string a caller handed in. On a
36
+ * case-insensitive disk the checker folds `Page.abide.shadow.ts` and `page.abide.shadow.ts` to
37
+ * one file, so a map telling them apart reported the second spelling `created` for a file the
38
+ * checker already held — it read nothing, and answered the second text's reads at the first
39
+ * text's offsets.
40
+ */
41
+ private shadows = new Map<string, { path: string; text: string }>()
42
+ /** The checker's own key for a path: resolved, and case-folded where its host folds case. */
43
+ readonly keyOf: (path: string) => string
44
+ /**
45
+ * Per `.abide` path as the checker keys it (`keyOf`), what a diagnosis last lowered it to.
46
+ *
47
+ * THE ONE THING CARRIED FROM ONE CALL TO ANOTHER, because an editor's hover is a request of
48
+ * its own that arrives after the check that lowered the file, and has to map its offset
49
+ * through the lowering the checker's program holds rather than one made again — and a span in
50
+ * another `.abide` maps back through that file's own.
51
+ */
52
+ private lowerings = new Map<string, LoweredFile>()
53
+
54
+ private constructor(
55
+ api: API,
56
+ tsconfig: string,
57
+ keyOf: (path: string) => string,
58
+ root: string,
59
+ ) {
60
+ this.api = api
61
+ this.tsconfig = tsconfig
62
+ this.root = root
63
+ this.opened = `${dirname(tsconfig)}/${SESSION_CONFIG}`
64
+ this.keyOf = keyOf
65
+ }
66
+
67
+ /**
68
+ * The project this host opens: the app's tsconfig, with every shadow in `outside` a root.
69
+ *
70
+ * 34.25 — A DEPENDENCY'S `.abide` IS TYPED AS THE APP'S OWN, and its tsconfig's `include` never
71
+ * reaches `node_modules` or a linked package's directory. The host opens a config of its own —
72
+ * served from here, never written — that extends the app's, names those shadows as files, which
73
+ * TypeScript adds to what the base config includes (a linked package's shadow, asked for through
74
+ * the link, is reached no other way), and allows the declaration a package's `exports` is asked
75
+ * for by (`standIn`).
76
+ */
77
+ private readonly opened: string
78
+
79
+ /** The shadows of the files outside the app's own, as `opened` names them. */
80
+ private outside = new Set<string>()
81
+
82
+ private sessionConfig(): string {
83
+ const config: {
84
+ extends: string
85
+ compilerOptions: Record<string, unknown>
86
+ files?: string[]
87
+ } = {
88
+ extends: `./${basename(this.tsconfig)}`,
89
+ // A package's `exports` names `./x.abide` exactly, so the checker asks for its
90
+ // declaration as `./x.d.abide.ts` (`standIn`) — and takes the answer only with this.
91
+ compilerOptions: { allowArbitraryExtensions: true },
92
+ }
93
+ // An empty `files` is a config error, and the base's `include` alone is the app.
94
+ if (this.outside.size > 0) config.files = [...this.outside]
95
+ return JSON.stringify(config)
96
+ }
97
+
98
+ /**
99
+ * Checked-module paths a stand-in answered for, without the `.abide` being lowered.
100
+ *
101
+ * 38.39 — A `.abide` THIS HOST HAS NOT LOWERED IS STILL A MODULE ANOTHER CAN IMPORT. What its
102
+ * checked module exports does not depend on what the component holds (`COMPONENT_MODULE`), so an
103
+ * editor checking one page resolves the components it imports without lowering each of them.
104
+ * Remembered, so the lowering that replaces a stand-in is reported as a change to it.
105
+ */
106
+ private stoodIn = new Set<string>()
107
+
108
+ /** The app's root, real. */
109
+ readonly root: string
110
+
111
+ /**
112
+ * `path` below the app's root, where it lies under it.
113
+ *
114
+ * 23.14 — what a page's route is read off: the directories below the root are it, and the
115
+ * machine's directories above the root are not.
116
+ */
117
+ belowRoot(path: string): string {
118
+ return path.startsWith(`${this.root}/`)
119
+ ? path.slice(this.root.length + 1)
120
+ : path
121
+ }
122
+
123
+ /**
124
+ * The shadow registered at `name`, or at the same file through its directory's real path.
125
+ *
126
+ * 34.25 — A LINKED PACKAGE IS ASKED FOR THROUGH ITS LINK: the checker resolves `kit/x.abide` to
127
+ * `node_modules/kit/x.d.abide.ts` and the re-export there to the shadow beside it, while the
128
+ * shadow is registered where bun resolved the file — the package's real directory.
129
+ */
130
+ private shadowAt(name: string): { path: string; text: string } | undefined {
131
+ const found = this.shadows.get(this.keyOf(name))
132
+ // Only a shadow is registered through a link, so any other name is spared the realpath.
133
+ if (
134
+ found !== undefined ||
135
+ !name.includes('/node_modules/') ||
136
+ !(name.endsWith(SHADOW_SUFFIX) || name.endsWith(CHECKED_SUFFIX))
137
+ )
138
+ return found
139
+ let real: string
140
+ try {
141
+ real = realpathSync(dirname(name))
142
+ } catch {
143
+ return undefined
144
+ }
145
+ return this.shadows.get(this.keyOf(`${real}/${basename(name)}`))
146
+ }
147
+
148
+ private standIn(name: string): string | undefined {
149
+ // 34.21 — a file imported as text is its text, which the projection names apart.
150
+ if (name.endsWith(`${SOURCE_SUFFIX}.d.ts`))
151
+ return isFile(name.slice(0, -`${SOURCE_SUFFIX}.d.ts`.length))
152
+ ? 'declare const text: string\nexport default text\n'
153
+ : undefined
154
+ // 34.17 — WHAT A `.abide` EXPORTS, to anything importing it: the component its checked
155
+ // module declares, and nothing else. The checker resolves `./x.abide` by trying
156
+ // `./x.abide.d.ts`; the bindings the projection re-exports for the build (16.70) are not
157
+ // the importer's to read.
158
+ //
159
+ // 34.25 — AND THROUGH A PACKAGE'S `exports`, which the checker follows to the exact file and
160
+ // so asks for the arbitrary extension's declaration, `./x.d.abide.ts`, instead.
161
+ const abide = name.endsWith(DECLARATION_SUFFIX)
162
+ ? name.slice(0, -'.d.ts'.length)
163
+ : name.endsWith(EXPORTED_DECLARATION_SUFFIX)
164
+ ? `${name.slice(0, -EXPORTED_DECLARATION_SUFFIX.length)}.abide`
165
+ : undefined
166
+ if (abide !== undefined) {
167
+ if (!isFile(abide)) return undefined
168
+ const own = `./${basename(abide)}`
169
+ return `export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
170
+ `
171
+ }
172
+ // A projection not registered — a file with nothing to read, or one this host has not
173
+ // projected yet — exports nothing, so the declaration above always resolves.
174
+ if (name.endsWith(`.abide${SHADOW_SUFFIX}`))
175
+ return isFile(name.slice(0, -SHADOW_SUFFIX.length))
176
+ ? 'export {}\n'
177
+ : undefined
178
+ if (!name.endsWith(`.abide${CHECKED_SUFFIX}`)) return undefined
179
+ if (!isFile(name.slice(0, -CHECKED_SUFFIX.length))) return undefined
180
+ this.stoodIn.add(this.keyOf(name))
181
+ return COMPONENT_MODULE
182
+ }
183
+
184
+ /**
185
+ * Opens a host on the project `tsconfig` configures, for the app at `root` — real, and empty
186
+ * for a host asked about no app's routes.
187
+ */
188
+ static async open(tsconfig: string, root = ''): Promise<ShadowHost> {
189
+ const api = new API({
190
+ cwd: dirname(tsconfig),
191
+ fs: {
192
+ readFile: (name) =>
193
+ name === host.opened
194
+ ? host.sessionConfig()
195
+ : (host.shadowAt(name)?.text ?? host.standIn(name)),
196
+ fileExists: (name) =>
197
+ name === host.opened ||
198
+ host.shadowAt(name) !== undefined ||
199
+ host.standIn(name) !== undefined
200
+ ? true
201
+ : undefined,
202
+ getAccessibleEntries: (directory) => host.entriesIn(directory),
203
+ },
204
+ })
205
+ // Whether the host folds case is the checker process's answer to `initialize`, and the API
206
+ // keeps it only inside the private `toPath` its own `getSourceFile` keys by — so that is
207
+ // what is read, rather than a second guess at the disk that could disagree with it.
208
+ const internals = api as unknown as {
209
+ ensureInitialized(): Promise<void>
210
+ toPath?: (path: string) => string
211
+ }
212
+ await internals.ensureInitialized()
213
+ const toPath = internals.toPath
214
+ if (typeof toPath !== 'function')
215
+ throw new Error(
216
+ 'abide: the TypeScript API no longer keeps `toPath` where the type session reads it, so a path cannot be keyed as the checker keys it.',
217
+ )
218
+ const host = new ShadowHost(api, tsconfig, toPath, root)
219
+ return host
220
+ }
221
+
222
+ /**
223
+ * The project as of the last snapshot, so a question asked after another shares its snapshot
224
+ * rather than taking a second one.
225
+ */
226
+ private latest: Project | undefined = undefined
227
+ private latestSnapshot: Snapshot | undefined = undefined
228
+
229
+ /**
230
+ * Snapshots a later one replaced, released once no call is in flight (`hold`).
231
+ *
232
+ * THE CHECKER PROCESS KEEPS EVERY SNAPSHOT UNTIL IT IS RELEASED — `updateSnapshot` releases
233
+ * nothing — and each holds a whole program and checker. Dropped unreleased, a kept session grew
234
+ * without bound: an `abide lsp` checker reached 59 GB in two hours of watcher-driven snapshots.
235
+ * Released the moment it is replaced, a question still being answered over it throws, so the
236
+ * release waits for the last call out.
237
+ */
238
+ private retired: Snapshot[] = []
239
+ private inFlight = 0
240
+
241
+ /**
242
+ * Marks a call in flight until the returned value is disposed (`using`); the last one out
243
+ * releases what was retired meanwhile. `ask` holds for as long as its question runs.
244
+ */
245
+ hold(): Disposable {
246
+ this.inFlight += 1
247
+ return {
248
+ [Symbol.dispose]: () => {
249
+ this.inFlight -= 1
250
+ if (this.inFlight === 0) this.releaseRetired()
251
+ },
252
+ }
253
+ }
254
+
255
+ private retire(snapshot: Snapshot): void {
256
+ this.retired.push(snapshot)
257
+ if (this.inFlight === 0) this.releaseRetired()
258
+ }
259
+
260
+ private releaseRetired(): void {
261
+ for (const snapshot of this.retired) {
262
+ // A process that has stopped answering rejects the release, and dies with what it held.
263
+ const release = snapshot.dispose().catch(() => {})
264
+ this.releasing.add(release)
265
+ void release.finally(() => this.releasing.delete(release))
266
+ }
267
+ this.retired = []
268
+ }
269
+
270
+ /** Releases sent and not yet answered, which `close` lets finish before ending the connection. */
271
+ private releasing = new Set<Promise<void>>()
272
+
273
+ /**
274
+ * Paths edited or deleted on disk since the last snapshot, or `everything`.
275
+ *
276
+ * WHAT THE AUTHOR CHANGED ON DISK since the last snapshot, for a host kept across compiles
277
+ * (`abide dev`). The checker caches every file it has read and is told only about the shadows
278
+ * this class writes, so an edit to a module a page imports answered with the type it had when
279
+ * the host opened: a name made reactive in a `.ts` stayed static, and a handler exported
280
+ * since was not there. `everything` is the edit nobody could name, and drops the whole cache.
281
+ */
282
+ private edits = { present: [] as string[], deleted: [] as string[] }
283
+ private everything = false
284
+
285
+ /**
286
+ * How many snapshots this host has taken.
287
+ *
288
+ * SNAPSHOTS TAKEN, and it is a counter rather than a timing because that is the only form of
289
+ * this claim that survives a loaded machine. One per build is the contract; one per FILE is
290
+ * the shape that made the checker 99.8% of compile time, and it reports the same answers.
291
+ */
292
+ snapshots = 0
293
+
294
+ /**
295
+ * A directory's listing as the checker sees it, with this host's shadows added.
296
+ *
297
+ * The shadow has to appear in its directory's listing or it is not a root of the project and
298
+ * the checker never reads it.
299
+ */
300
+ private entriesIn(
301
+ directory: string,
302
+ ): { files: string[]; directories: string[] } | undefined {
303
+ // TWO PATHS TO ONE DIRECTORY ARE ONE DIRECTORY. A raw string compare said otherwise on a
304
+ // doubled separator, a trailing slash or a relative path, and the shadow then appeared in
305
+ // nobody's listing — so the checker never read it and the refusal below blamed the app's
306
+ // tsconfig for a path this class had mangled.
307
+ const here = this.keyOf(directory)
308
+ this.listed.add(here)
309
+ const mine: string[] = []
310
+ // The key is the checker's where it is registered, so only the incoming directory needs
311
+ // keying — one `toPath` a call rather than one a shadow.
312
+ for (const [key, shadow] of this.shadows)
313
+ if (dirname(key) === here) mine.push(basename(shadow.path))
314
+ if (mine.length === 0) return undefined
315
+
316
+ const files: string[] = []
317
+ const directories: string[] = []
318
+ // A DIRECTORY THAT IS NOT THERE IS A LISTING OF THE SHADOWS ALONE, not a throw. The
319
+ // checker calls this over the api and a throw crosses back as a remote error, which
320
+ // PANICS the checker process — so a directory deleted between the glob and the ask took
321
+ // the whole command down with it. `abide dev` is where that happens as a matter of
322
+ // course: a generator that rewrites a tree of pages deletes every one of them first, and
323
+ // the watcher fires while it is doing it.
324
+ let entries: Dirent[]
325
+ try {
326
+ entries = readdirSync(directory, { withFileTypes: true })
327
+ } catch (thrown) {
328
+ if ((thrown as { code?: unknown }).code !== 'ENOENT') throw thrown
329
+ return { files: mine, directories: [] }
330
+ }
331
+ // THE LISTING ALREADY KNOWS. A `statSync` per entry was N syscalls for an answer
332
+ // `readdirSync` hands back with the name, and it THREW on a dangling symlink — an Emacs
333
+ // `.#page.abide` lock beside a page took the whole build down with it.
334
+ for (const entry of entries) {
335
+ // A symlink is the one entry the listing cannot answer for, and a dangling one has no
336
+ // answer at all — which is a file as far as this is concerned.
337
+ if (entry.isSymbolicLink()) {
338
+ let followed = false
339
+ try {
340
+ followed = statSync(
341
+ join(directory, entry.name),
342
+ ).isDirectory()
343
+ } catch {
344
+ // Dangling: a file, as `followed` already says.
345
+ }
346
+ if (followed) directories.push(entry.name)
347
+ else files.push(entry.name)
348
+ continue
349
+ }
350
+ if (entry.isDirectory()) directories.push(entry.name)
351
+ else files.push(entry.name)
352
+ }
353
+ for (const name of mine) if (!files.includes(name)) files.push(name)
354
+ return { files, directories }
355
+ }
356
+
357
+ /**
358
+ * Records the paths a watcher saw move, for the next snapshot to invalidate.
359
+ *
360
+ * THE PATHS A WATCHER SAW MOVE, or `undefined` where it saw a move and could not say where.
361
+ * A file that is there is reported CREATED AND CHANGED: `changed` alone leaves a new file out
362
+ * of the project's root set (a `.d.ts` nothing imports is never read), and `created` alone
363
+ * leaves an edited file's cached text in place. A directory is dropped wholesale, because the
364
+ * checker is not told what was under it.
365
+ */
366
+ edited(paths: string[] | undefined): void {
367
+ if (paths === undefined) {
368
+ this.everything = true
369
+ return
370
+ }
371
+ for (const path of paths) {
372
+ let directory: boolean
373
+ try {
374
+ directory = statSync(path).isDirectory()
375
+ } catch {
376
+ this.edits.deleted.push(path)
377
+ // A `.abide` that is gone takes its shadows with it, or the checker keeps a page
378
+ // nobody has as a root of the project for the rest of the session — and a page
379
+ // importing it goes on reading the props its checked module declared.
380
+ const gone = this.keyOf(path)
381
+ for (const [key, shadow] of this.shadows)
382
+ if (
383
+ ShadowMap.isShadowOf(key, gone) ||
384
+ key.startsWith(`${gone}/`)
385
+ ) {
386
+ this.shadows.delete(key)
387
+ this.edits.deleted.push(shadow.path)
388
+ }
389
+ continue
390
+ }
391
+ // A FILE IN A DIRECTORY THE CHECKER HAS NEVER LISTED is a directory move the watcher
392
+ // reported as the file alone — a save that creates `late/` and the page in it
393
+ // at once — and the listing it already holds of the parent does not have it.
394
+ if (directory || !this.listed.has(this.keyOf(dirname(path))))
395
+ this.everything = true
396
+ else this.edits.present.push(path)
397
+ }
398
+ }
399
+
400
+ /** Every directory the checker has asked this host to list (`entriesIn`). */
401
+ private listed = new Set<string>()
402
+
403
+ /**
404
+ * ONE SNAPSHOT TAKES EVERY EDIT WITH IT: the shadows written for it and whatever moved on disk.
405
+ */
406
+ private async snapshot(
407
+ created: string[],
408
+ changed: string[],
409
+ ): Promise<Project | undefined> {
410
+ const edits = this.edits
411
+ const everything = this.everything
412
+ this.edits = { present: [], deleted: [] }
413
+ this.everything = false
414
+ this.snapshots += 1
415
+ // AN EDIT NOBODY COULD NAME DROPS THE CACHE, and the files created with it are still named
416
+ // after it: a dropped cache re-reads what the project already listed, and a shadow written
417
+ // for a page in a directory it never listed is not among them.
418
+ if (everything)
419
+ this.retire(
420
+ await this.api.updateSnapshot({
421
+ openProjects: [this.opened],
422
+ fileChanges: { invalidateAll: true },
423
+ }),
424
+ )
425
+ const snapshot = await this.api.updateSnapshot({
426
+ openProjects: [this.opened],
427
+ fileChanges: {
428
+ created: [...created, ...edits.present],
429
+ changed: [...changed, ...edits.present],
430
+ deleted: edits.deleted,
431
+ },
432
+ })
433
+ if (this.latestSnapshot !== undefined) this.retire(this.latestSnapshot)
434
+ this.latestSnapshot = snapshot
435
+ this.latest = snapshot.getProjects()[0]
436
+ return this.latest
437
+ }
438
+
439
+ /** Whether the last snapshot still answers: there is one, and nothing has moved since. */
440
+ private get current(): boolean {
441
+ return (
442
+ this.latest !== undefined &&
443
+ !this.everything &&
444
+ this.edits.present.length === 0 &&
445
+ this.edits.deleted.length === 0
446
+ )
447
+ }
448
+
449
+ private async currentProject(
450
+ created: string[] = [],
451
+ changed: string[] = [],
452
+ ): Promise<Project | undefined> {
453
+ return created.length === 0 && changed.length === 0 && this.current
454
+ ? this.latest
455
+ : await this.snapshot(created, changed)
456
+ }
457
+
458
+ /**
459
+ * 34.25, 23.19 — where `source` is a dependency's file, makes `path` — it, or what it lowers to
460
+ * — a root of the opened config, telling `edit` the config moved where it was not one already.
461
+ * The app's own is a root already: telling the config it moved reloads the project.
462
+ */
463
+ openOutside(source: string, path: string, edit: ShadowEdit): void {
464
+ if (placeOf(this.belowRoot(source)) !== 'dependency') return
465
+ if (this.outside.has(path)) return
466
+ this.outside.add(path)
467
+ if (!edit.changed.includes(this.opened)) edit.changed.push(this.opened)
468
+ }
469
+
470
+ /**
471
+ * `question` asked of the project, held until it is answered — or `undefined` where the checker
472
+ * opened none.
473
+ *
474
+ * THE ONE WAY A QUESTION REACHES THE CHECKER. The project is the one `edit` moves to — the last
475
+ * snapshot where nothing moved since — or, `latest`, the last snapshot as it stands: an editor's
476
+ * question is asked of the program its check registered, whatever a watcher heard since.
477
+ *
478
+ * THE SNAPSHOT IS THE HOST'S, AND A QUESTION IS ANY MODULE'S: what a handler declares
479
+ * (`declaredExports`) is asked of the same snapshot the reads were, taken only where something
480
+ * moved since. 23.19 — a module of `modules` a mount places is a package's, which the app's
481
+ * `include` does not reach: it is made a root, as a mounted `.abide`'s shadow is.
482
+ */
483
+ async ask<Answer>(
484
+ question: (project: Project) => Promise<Answer>,
485
+ asking: {
486
+ edit?: ShadowEdit
487
+ modules?: readonly string[]
488
+ latest?: boolean
489
+ } = {},
490
+ ): Promise<Answer | undefined> {
491
+ using _held = this.hold()
492
+ const project = asking.latest
493
+ ? this.latest
494
+ : await this.projectWith(
495
+ asking.modules ?? [],
496
+ asking.edit ?? { created: [], changed: [] },
497
+ )
498
+ return project === undefined ? undefined : await question(project)
499
+ }
500
+
501
+ private async projectWith(
502
+ modules: readonly string[],
503
+ edit: ShadowEdit,
504
+ ): Promise<Project | undefined> {
505
+ for (const path of modules) {
506
+ const canonical = this.canonical(path)
507
+ this.openOutside(canonical, canonical, edit)
508
+ }
509
+ const project = await this.currentProject(edit.created, edit.changed)
510
+ if (project === undefined || modules.length === 0) return project
511
+ // A KEPT HOST HEARS OF A FILE ONLY AS A WATCHER REPORTS IT, and one saved into a
512
+ // directory made since the last snapshot is reported as the directory: it is no root yet,
513
+ // and is told to the next snapshot as created.
514
+ const found = await Promise.all(
515
+ modules.map((path) => project.program.getSourceFile(path)),
516
+ )
517
+ const missing: string[] = []
518
+ for (let at = 0; at < modules.length; at += 1)
519
+ if (found[at] === undefined) missing.push(modules[at] as string)
520
+ return missing.length === 0
521
+ ? project
522
+ : await this.currentProject(missing, [...missing])
523
+ }
524
+
525
+ /**
526
+ * The checker's tree of `path` in `project`, or a refusal naming `file` — the one the author
527
+ * wrote — and what cannot be done without it.
528
+ *
529
+ * The file has to be INSIDE the opened project or the checker never reads it. Refusing loudly
530
+ * rather than answering nothing: no reads is a file whose every name lowers as written, which
531
+ * renders `[object Object]` where a value was due — a wrong page, with nothing about it saying
532
+ * so — and no exports is a handler that never answers.
533
+ */
534
+ async sourceFile(
535
+ project: Project,
536
+ path: string,
537
+ file: string,
538
+ unanswered: string,
539
+ ): Promise<SourceFile> {
540
+ const sourceFile = await project.program.getSourceFile(path)
541
+ if (sourceFile === undefined)
542
+ throw new Error(
543
+ `abide: ${file} is not inside the project ${this.tsconfig}, so ${unanswered}. Give the app a tsconfig whose include reaches it.`,
544
+ )
545
+ return sourceFile
546
+ }
547
+
548
+ /**
549
+ * THE APP'S OWN FILES the checker holds as roots: under `within`, and neither a dependency nor a
550
+ * declaration file. Resolved against the tsconfig's directory; a caller asking `holds` keys it.
551
+ */
552
+ rootsUnder(project: Project, within: string): string[] {
553
+ const root = `${this.canonical(within)}/`
554
+ const roots: string[] = []
555
+ for (const path of project.rootFiles) {
556
+ const canonical = this.canonical(path)
557
+ if (
558
+ canonical.startsWith(root) &&
559
+ !path.includes('/node_modules/') &&
560
+ !path.endsWith('.d.ts')
561
+ )
562
+ roots.push(canonical)
563
+ }
564
+ return roots
565
+ }
566
+
567
+ /**
568
+ * A relative path from the checker is relative to the API's own cwd, which is the tsconfig's
569
+ * directory rather than the process's.
570
+ */
571
+ canonical(path: string): string {
572
+ return resolve(dirname(this.tsconfig), path)
573
+ }
574
+
575
+ /**
576
+ * Where the checker holds a `.abide`'s projection, once its reads have registered it.
577
+ *
578
+ * 16.70 — what a `<script server>` binds is read off it as a `.ts` module's exports are.
579
+ */
580
+ projectionOf(abidePath: string): string {
581
+ return `${this.canonical(abidePath)}${SHADOW_SUFFIX}`
582
+ }
583
+
584
+ /** Whether the checker reads `path` out of this host rather than off the disk. */
585
+ holds(path: string): boolean {
586
+ return this.shadows.has(this.keyOf(path))
587
+ }
588
+
589
+ /**
590
+ * A file the checker reads out of this host, reported to `edit` only when it is new, its text
591
+ * moved, or a stand-in answered for it until now.
592
+ *
593
+ * A file STAYS registered for the life of the host and the snapshot is told it changed.
594
+ * Deleting and re-adding under the same path does not invalidate the source-file cache, and the
595
+ * checker then answers about the PREVIOUS file at these offsets — right-looking types for the
596
+ * wrong names, with nothing to say so.
597
+ */
598
+ write(path: string, text: string, edit: ShadowEdit): void {
599
+ const key = this.keyOf(path)
600
+ const previous = this.shadows.get(key)
601
+ const stoodIn = this.stoodIn.delete(key)
602
+ if (previous === undefined) edit.created.push(path)
603
+ if (stoodIn || (previous !== undefined && previous.text !== text))
604
+ edit.changed.push(path)
605
+ this.shadows.set(key, { path, text })
606
+ }
607
+
608
+ /** The lowering a diagnosis last registered for the `.abide` at `path`, whatever its text. */
609
+ lowered(path: string): LoweredFile | undefined {
610
+ return this.lowerings.get(this.keyOf(path))
611
+ }
612
+
613
+ /** Keeps `lowered` as its file's lowering, for the questions asked of it later. */
614
+ keep(lowered: LoweredFile): void {
615
+ this.lowerings.set(this.keyOf(lowered.map.abidePath), lowered)
616
+ }
617
+
618
+ /**
619
+ * Closes the checker, and ends its process.
620
+ *
621
+ * KILLED, NOT LEFT TO END ITSELF. The API's own close only ends the process's stdin, and a
622
+ * `tsgo` that does not read the end of it lives on with this process's stderr in hand — one
623
+ * outlived its build by eight hours, and anything reading that build's stderr to its end
624
+ * (`abide build 2>&1 | tee`, a test's pipe) waits as long. The API holds the process on a
625
+ * private field and forgets it as it closes, so it is taken first; `typeSession.test.ts` gates
626
+ * that the field is still where this reads it.
627
+ *
628
+ * THE CLIENT IS CLOSED, NOT THE API: the API's close first asks the process to drop each
629
+ * snapshot, and one that has stopped answering never settles it. The client's asks nothing —
630
+ * it ends the connection, which rejects what was in flight — and the snapshots die with the
631
+ * process.
632
+ */
633
+ async close(): Promise<void> {
634
+ const client = (
635
+ this.api as unknown as {
636
+ client: {
637
+ process?: { kill(): boolean }
638
+ close(): Promise<void>
639
+ }
640
+ }
641
+ ).client
642
+ const child = client.process
643
+ // A release still being written when the connection ends is a write after end, thrown
644
+ // where nothing catches it. Bounded, for the same process that has stopped answering.
645
+ if (this.releasing.size > 0)
646
+ await Promise.race([
647
+ Promise.allSettled(this.releasing),
648
+ Bun.sleep(RELEASE_GRACE_MS),
649
+ ])
650
+ await client.close()
651
+ child?.kill()
652
+ }
653
+ }
654
+
655
+ // How long `close` waits on releases in flight before ending the connection under them.
656
+ const RELEASE_GRACE_MS = 1000
657
+
658
+ // What the checker asks for when it resolves an import of `x.abide` (`standIn`).
659
+ const DECLARATION_SUFFIX = '.abide.d.ts'
660
+ // Beside the app's tsconfig, so `extends` and every path in it resolve as they would from there.
661
+ const SESSION_CONFIG = 'tsconfig.abide-session.json'
662
+ const EXPORTED_DECLARATION_SUFFIX = '.d.abide.ts'
663
+
664
+ function isFile(path: string): boolean {
665
+ try {
666
+ return statSync(path).isFile()
667
+ } catch {
668
+ return false
669
+ }
670
+ }
@@ -14,7 +14,7 @@ export const SHADOW_SUFFIX = '.shadow.ts'
14
14
  * the first alone — and which one a position is in is the file the checker named, so the map owns
15
15
  * the two suffixes as well as the two `origins`.
16
16
  *
17
- * Built over the projection alone where nothing has been lowered yet (`TypeSession.classify`), and
17
+ * Built over the projection alone where nothing has been lowered yet (`reactiveReads`), and
18
18
  * over both once it has.
19
19
  */
20
20
  export class ShadowMap {