@abide/abide 0.123.3 → 0.124.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/ABIDE.md +8 -5
- package/CHANGELOG.md +38 -0
- package/docs/REGISTRY.md +45 -12
- package/docs/RULEBOOK.md +29 -16
- package/docs/abide.md +999 -459
- package/package.json +1 -1
- package/src/compiler/ARRAY_MUTATORS.ts +20 -0
- package/src/compiler/LoweredFile.ts +609 -0
- package/src/compiler/Lowering.ts +46 -4
- package/src/compiler/SHARED_NAMES.ts +2 -0
- package/src/compiler/ShadowHost.ts +670 -0
- package/src/compiler/ShadowMap.ts +1 -1
- package/src/compiler/TypeSession.ts +71 -1715
- package/src/compiler/appLints.ts +109 -0
- package/src/compiler/checkedShadow.ts +1 -1
- package/src/compiler/classifyRead.ts +16 -1
- package/src/compiler/compileApp.ts +2 -2
- package/src/compiler/declaredExports.ts +44 -17
- package/src/compiler/diagnoseSources.ts +232 -0
- package/src/compiler/formatAbide.ts +5 -6
- package/src/compiler/handlersUnder.ts +16 -35
- package/src/compiler/projectShadow.ts +1 -0
- package/src/compiler/reactiveReads.ts +235 -0
- package/src/compiler/reactiveTypes.ts +13 -0
- package/src/compiler/readsIn.ts +180 -81
- package/src/compiler/routesDeclaration.ts +46 -0
- package/src/compiler/scaffold.ts +7 -2
- package/src/compiler/serveLanguage.ts +26 -20
- package/src/compiler/splitImports.ts +2 -2
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/DesktopSession.ts +213 -0
- package/src/server/Followers.ts +399 -0
- package/src/server/MODULE_QUERIES.ts +6 -2
- package/src/server/PageRender.ts +201 -0
- package/src/server/abideModules.ts +7 -7
- package/src/server/answerFailure.ts +3 -2
- package/src/server/answerRequest.ts +6 -70
- package/src/server/answerRpc.ts +48 -260
- package/src/server/answerSocket.ts +41 -463
- package/src/server/appKit.ts +1 -1
- package/src/server/argsContract.ts +1 -1
- package/src/server/createApp.ts +8 -2
- package/src/server/csp.ts +1 -1
- package/src/server/deferredBuilds.ts +3 -16
- package/src/server/faceOf.ts +1 -1
- package/src/server/generatedSurfaces.ts +2 -1
- package/src/server/handlerFileMount.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/installServer.ts +5 -9
- package/src/server/invokeHandler.ts +230 -0
- package/src/server/mountHandlers.ts +184 -0
- package/src/server/openDesktopWindow.ts +308 -0
- package/src/server/preload.ts +18 -0
- package/src/server/principal.ts +1 -1
- package/src/server/remote.ts +1 -1
- package/src/server/render.ts +31 -59
- package/src/server/requestScope.ts +5 -36
- package/src/server/runDesktop.ts +19 -447
- package/src/server/seedBuffer.ts +11 -11
- package/src/server/servedApp.ts +1 -1
- package/src/server/shell.ts +1 -1
- package/src/server/surfaceOf.ts +1 -1
- package/src/server/versionOf.ts +1 -1
- package/src/shared/RPC_SERVER.ts +27 -31
- package/src/shared/Schema.ts +1 -2
- package/src/shared/index.ts +1 -1
- package/src/shared/{note.ts → issueReport.ts} +22 -0
- package/src/shared/loadCall.ts +66 -0
- package/src/shared/reactive/Keyed.ts +23 -15
- package/src/shared/reactive/Reactive.ts +46 -1002
- package/src/shared/reactive/byOf.ts +2 -5
- package/src/shared/reactive/callLoad.ts +8 -14
- package/src/shared/reactive/cellShape.ts +734 -0
- package/src/shared/reactive/index.ts +12 -24
- package/src/shared/reactive/pairReactive.ts +5 -3
- package/src/shared/reactive/producer.ts +121 -0
- package/src/shared/reactive/scoped.ts +3 -2
- package/src/shared/reactive/selections.ts +209 -0
- package/src/shared/reactive/watch.ts +3 -4
- package/src/shared/rpc.ts +8 -5
- package/src/shared/validateJson.ts +1 -2
- package/src/shared/writePath.ts +29 -0
- package/src/ui/Router.ts +53 -69
- package/src/ui/outboxQueue.ts +2 -8
- package/src/ui/outcomeOf.ts +120 -0
- package/src/ui/socketWire.ts +3 -3
- package/src/ui/wire.ts +9 -10
- package/src/shared/LOAD_OFFLINE.ts +0 -15
- package/src/shared/LOAD_SIGNAL.ts +0 -10
- package/src/shared/issuesOf.ts +0 -18
- 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 (`
|
|
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 {
|