@abide/abide 0.101.0 → 0.103.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 (161) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/abide.ts +226 -164
  3. package/docs/REGISTRY.md +730 -0
  4. package/docs/RULEBOOK.md +2768 -0
  5. package/docs/abide.md +13127 -0
  6. package/package.json +6 -3
  7. package/scaffold/AGENTS.md +38 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +341 -71
  13. package/src/compiler/appMap.ts +96 -0
  14. package/src/compiler/argsSchemaOf.ts +1 -1
  15. package/src/compiler/checkApp.ts +53 -8
  16. package/src/compiler/checkedShadow.ts +73 -6
  17. package/src/compiler/classifyRead.ts +6 -4
  18. package/src/compiler/classifyTemplate.ts +1 -1
  19. package/src/compiler/compileApp.ts +598 -200
  20. package/src/compiler/compiledEntry.ts +48 -40
  21. package/src/compiler/componentGraph.ts +57 -71
  22. package/src/compiler/declaredExports.ts +26 -9
  23. package/src/compiler/emitClient.ts +142 -72
  24. package/src/compiler/emitComponent.ts +41 -12
  25. package/src/compiler/emitSource.ts +102 -13
  26. package/src/compiler/handlersUnder.ts +164 -76
  27. package/src/compiler/hooksCalled.ts +35 -0
  28. package/src/compiler/index.ts +7 -0
  29. package/src/compiler/mountsOf.ts +150 -0
  30. package/src/compiler/nearest.ts +14 -0
  31. package/src/compiler/parseAbide.ts +157 -20
  32. package/src/compiler/projectShadow.ts +250 -19
  33. package/src/compiler/reactiveTypes.ts +20 -7
  34. package/src/compiler/readSources.ts +15 -2
  35. package/src/compiler/readsIn.ts +62 -8
  36. package/src/compiler/renderMarkup.ts +3 -3
  37. package/src/compiler/scaffold.ts +37 -15
  38. package/src/compiler/serveLanguage.ts +101 -34
  39. package/src/compiler/sharedModuleWrites.ts +2 -2
  40. package/src/compiler/sourceMapOf.ts +193 -0
  41. package/src/compiler/splitImports.ts +205 -83
  42. package/src/compiler/tokenize.ts +14 -0
  43. package/src/compiler/writeDefaultProject.ts +29 -2
  44. package/src/server/CLIENT_BUILD.ts +11 -13
  45. package/src/server/DEFAULT_PORT.ts +1 -1
  46. package/src/server/MODULE_QUERIES.ts +27 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +457 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +241 -107
  52. package/src/server/answerRpc.ts +107 -33
  53. package/src/server/answerSocket.ts +65 -20
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +238 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +38 -37
  65. package/src/server/componentModules.d.ts +2 -1
  66. package/src/server/compressAnswer.ts +4 -4
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +77 -79
  70. package/src/server/devWorker.ts +118 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +57 -32
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +137 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +12 -7
  77. package/src/server/installSourceMaps.ts +93 -0
  78. package/src/server/launcherOptions.ts +33 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +6 -6
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/preload.ts +13 -0
  85. package/src/server/principal.ts +10 -5
  86. package/src/server/realOrAsGiven.ts +10 -0
  87. package/src/server/refuseAddressCollision.ts +21 -14
  88. package/src/server/remote.ts +49 -52
  89. package/src/server/render.ts +65 -94
  90. package/src/server/renderComponent.ts +5 -0
  91. package/src/server/reportHealth.ts +2 -2
  92. package/src/server/requestScope.ts +12 -1
  93. package/src/server/runBuild.ts +24 -0
  94. package/src/server/runCompiled.ts +5 -5
  95. package/src/server/runDesktop.ts +48 -28
  96. package/src/server/serveBuild.ts +43 -52
  97. package/src/server/serveDev.ts +79 -37
  98. package/src/server/shell.ts +7 -22
  99. package/src/server/sourceMappings.ts +139 -0
  100. package/src/server/surfaceOf.ts +20 -3
  101. package/src/server/testBuild.ts +1 -19
  102. package/src/server/withAbideSources.ts +117 -0
  103. package/src/shared/ADDRESSES.ts +10 -18
  104. package/src/shared/APP_META.ts +7 -0
  105. package/src/shared/CopyOptions.ts +1 -1
  106. package/src/shared/DEBUG_META.ts +1 -1
  107. package/src/shared/LAUNCHER_FILES.ts +9 -0
  108. package/src/shared/RESERVED_DIRECTORIES.ts +24 -0
  109. package/src/shared/cookies.ts +2 -2
  110. package/src/shared/csp.ts +1 -1
  111. package/src/shared/escapeText.ts +1 -1
  112. package/src/shared/filledPath.ts +85 -0
  113. package/src/shared/framing.ts +79 -6
  114. package/src/shared/headKeyOf.ts +2 -0
  115. package/src/shared/index.ts +2 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/{ui → shared}/menuBridge.ts +2 -2
  119. package/src/shared/menuItem.ts +4 -4
  120. package/src/shared/onMenu.ts +58 -0
  121. package/src/shared/reactive/Keyed.ts +2 -2
  122. package/src/shared/reactive/NotLanded.ts +1 -1
  123. package/src/shared/reactive/Reactive.ts +43 -43
  124. package/src/shared/reactive/argsContract.ts +4 -4
  125. package/src/shared/reactive/canonicalKey.ts +4 -4
  126. package/src/shared/reactive/chunksOfStream.ts +6 -0
  127. package/src/shared/reactive/index.ts +1 -1
  128. package/src/shared/reactive/recordWrites.ts +103 -5
  129. package/src/shared/reactive/structural.ts +1 -1
  130. package/src/shared/reactive/tracking.ts +1 -1
  131. package/src/shared/reactive/watch.ts +3 -3
  132. package/src/shared/refuse.ts +1 -1
  133. package/src/{ui → shared}/reportMenu.ts +8 -10
  134. package/src/shared/routePattern.ts +122 -48
  135. package/src/shared/rpc.ts +37 -8
  136. package/src/shared/rpcRequest.ts +27 -3
  137. package/src/shared/url.ts +4 -44
  138. package/src/shared/writePath.ts +1 -1
  139. package/src/ui/LAYER_NAMES.ts +1 -1
  140. package/src/ui/List.ts +3 -3
  141. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  142. package/src/ui/Mismatch.ts +1 -1
  143. package/src/ui/RawRun.ts +1 -1
  144. package/src/ui/Router.ts +37 -36
  145. package/src/ui/Scope.ts +1 -1
  146. package/src/ui/TryRegion.ts +2 -2
  147. package/src/ui/applyHead.ts +33 -0
  148. package/src/ui/index.ts +1 -1
  149. package/src/ui/moveNode.ts +1 -1
  150. package/src/ui/reconcile.ts +2 -2
  151. package/src/ui/routing.ts +2 -2
  152. package/src/ui/setAttribute.ts +1 -1
  153. package/src/ui/setText.ts +1 -1
  154. package/src/ui/socketWire.ts +9 -7
  155. package/src/ui/wire.ts +1 -1
  156. package/scaffold/src/server/app.ts +0 -6
  157. package/scaffold/src/shared/.gitkeep +0 -0
  158. package/scaffold/src/ui/app.html +0 -8
  159. package/src/server/componentImports.ts +0 -56
  160. /package/scaffold/src/{ui/pages/page.abide → index.abide} +0 -0
  161. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
@@ -1,16 +1,18 @@
1
- import { type Dirent, readdirSync, statSync } from 'node:fs' // Bun exposes no sync directory listing of its own
1
+ import { type Dirent, readdirSync, realpathSync, statSync } from 'node:fs' // Bun exposes no sync directory listing or realpath of its own
2
2
  import { basename, dirname, join, resolve } from 'node:path' // Bun exposes no path helpers of its own
3
3
  import { type Node, NodeFlags, SyntaxKind } from 'typescript/unstable/ast'
4
4
  import {
5
5
  API,
6
6
  type Symbol as CheckedSymbol,
7
7
  type Checker,
8
+ type CompletionInfo,
8
9
  NodeBuilderFlags,
9
10
  type Project,
10
11
  SignatureKind,
11
12
  SymbolFlags,
12
13
  } from 'typescript/unstable/async'
13
- import { fileKind, routeOf } from '#shared/routePattern.ts'
14
+ import { appFiles } from '#server/appTree.ts'
15
+ import { fileKind, placeOf, routeOf } from '#shared/routePattern.ts'
14
16
  import { aliasedSymbol } from './aliasedSymbol.ts'
15
17
  import { AMBIENT_SOURCES, ambientReadsIn } from './ambientReadsIn.ts'
16
18
  import { argsDefaultsIn } from './argsDefaultsIn.ts'
@@ -19,16 +21,19 @@ import {
19
21
  COMPONENT_MODULE,
20
22
  COMPONENT_STAND_IN,
21
23
  checkedShadow,
24
+ moduleConfigShadow,
22
25
  } from './checkedShadow.ts'
23
26
  import type { Finding } from './Finding.ts'
24
27
  import { CHECKED_VALUES } from './Lowering.ts'
25
28
  import { diagnosisAt, lineOf } from './lineOf.ts'
29
+ import { mountedUnder } from './mountsOf.ts'
26
30
  import type { AbideFile } from './parseAbide.ts'
27
31
  import { projectShadow } from './projectShadow.ts'
28
32
  import { abideIn } from './reactiveTypes.ts'
29
33
  import type { ParsedSource, ReadSource } from './readSources.ts'
30
34
  import { readsIn, type ShadowSpans } from './readsIn.ts'
31
- import { CHECKED_SUFFIX, ShadowMap } from './ShadowMap.ts'
35
+ import { CHECKED_SUFFIX, SHADOW_SUFFIX, ShadowMap } from './ShadowMap.ts'
36
+ import { SOURCE_SUFFIX } from './splitImports.ts'
32
37
  import { templateWarnings } from './templateWarnings.ts'
33
38
 
34
39
  // Where 31.3 gets its answer. A `.abide` file is projected into a SHADOW `.ts` held in a virtual
@@ -60,6 +65,18 @@ export class TypeSession {
60
65
  * text's offsets.
61
66
  */
62
67
  private shadows = new Map<string, { path: string; text: string }>()
68
+ /**
69
+ * What `diagnose` last read off one walk of the root: the route table it declared (30.26) and the
70
+ * modules it read for their `onConfig` (27.13). Kept until `edited` hears anything move, so a
71
+ * check asked again and again while an editor types walks and reads nothing on disk.
72
+ */
73
+ private walked:
74
+ | {
75
+ within: string
76
+ routes: string
77
+ configModules: Promise<[string, string][]>
78
+ }
79
+ | undefined = undefined
63
80
  /** The checker's own key for a path: resolved, and case-folded where its host folds case. */
64
81
  private keyOf: (path: string) => string
65
82
  /**
@@ -83,27 +100,123 @@ export class TypeSession {
83
100
  ) {
84
101
  this.api = api
85
102
  this.tsconfig = tsconfig
103
+ this.opened = `${dirname(tsconfig)}/${SESSION_CONFIG}`
86
104
  this.keyOf = keyOf
87
105
  }
88
106
 
107
+ /**
108
+ * The project this session opens: the app's tsconfig, with every shadow in `outside` a root.
109
+ *
110
+ * 34.25 — A DEPENDENCY'S `.abide` IS TYPED AS THE APP'S OWN, and its tsconfig's `include` never
111
+ * reaches `node_modules` or a linked package's directory. The session opens a config of its own —
112
+ * served from here, never written — that extends the app's, names those shadows as files, which
113
+ * TypeScript adds to what the base config includes (a linked package's shadow, asked for through
114
+ * the link, is reached no other way), and allows the declaration a package's `exports` is asked
115
+ * for by (`standIn`).
116
+ */
117
+ private readonly opened: string
118
+
119
+ /** The shadows of the files outside the app's own, as `opened` names them. */
120
+ private outside = new Set<string>()
121
+
122
+ private sessionConfig(): string {
123
+ const config: {
124
+ extends: string
125
+ compilerOptions: Record<string, unknown>
126
+ files?: string[]
127
+ } = {
128
+ extends: `./${basename(this.tsconfig)}`,
129
+ // A package's `exports` names `./x.abide` exactly, so the checker asks for its
130
+ // declaration as `./x.d.abide.ts` (`standIn`) — and takes the answer only with this.
131
+ compilerOptions: { allowArbitraryExtensions: true },
132
+ }
133
+ // An empty `files` is a config error, and the base's `include` alone is the app.
134
+ if (this.outside.size > 0) config.files = [...this.outside]
135
+ return JSON.stringify(config)
136
+ }
137
+
89
138
  /**
90
139
  * Checked-module paths a stand-in answered for, without the `.abide` being lowered.
91
140
  *
92
- * 38.40 — A `.abide` THIS SESSION HAS NOT LOWERED IS STILL A MODULE ANOTHER CAN IMPORT. What its
141
+ * 38.39 — A `.abide` THIS SESSION HAS NOT LOWERED IS STILL A MODULE ANOTHER CAN IMPORT. What its
93
142
  * checked module exports does not depend on what the component holds (`COMPONENT_MODULE`), so an
94
143
  * editor checking one page resolves the components it imports without lowering each of them.
95
144
  * Remembered, so the lowering that replaces a stand-in is reported as a change to it.
96
145
  */
97
146
  private stoodIn = new Set<string>()
98
147
 
99
- private standIn(name: string): string | undefined {
100
- if (!name.endsWith(`.abide${CHECKED_SUFFIX}`)) return undefined
148
+ /** The app's root, real — set by `openChecker`. */
149
+ root = ''
150
+
151
+ /**
152
+ * `path` below the app's root, where it lies under it.
153
+ *
154
+ * 23.14 — what a page's route is read off: the directories below the root are it, and the
155
+ * machine's directories above the root are not.
156
+ */
157
+ belowRoot(path: string): string {
158
+ return path.startsWith(`${this.root}/`)
159
+ ? path.slice(this.root.length + 1)
160
+ : path
161
+ }
162
+
163
+ /**
164
+ * The shadow registered at `name`, or at the same file through its directory's real path.
165
+ *
166
+ * 34.25 — A LINKED PACKAGE IS ASKED FOR THROUGH ITS LINK: the checker resolves `kit/x.abide` to
167
+ * `node_modules/kit/x.d.abide.ts` and the re-export there to the shadow beside it, while the
168
+ * shadow is registered where bun resolved the file — the package's real directory.
169
+ */
170
+ private shadowAt(name: string): { path: string; text: string } | undefined {
171
+ const found = this.shadows.get(this.keyOf(name))
172
+ // Only a shadow is registered through a link, so any other name is spared the realpath.
173
+ if (
174
+ found !== undefined ||
175
+ !name.includes('/node_modules/') ||
176
+ !(name.endsWith(SHADOW_SUFFIX) || name.endsWith(CHECKED_SUFFIX))
177
+ )
178
+ return found
179
+ let real: string
101
180
  try {
102
- if (!statSync(name.slice(0, -CHECKED_SUFFIX.length)).isFile())
103
- return undefined
181
+ real = realpathSync(dirname(name))
104
182
  } catch {
105
183
  return undefined
106
184
  }
185
+ return this.shadows.get(this.keyOf(`${real}/${basename(name)}`))
186
+ }
187
+
188
+ private standIn(name: string): string | undefined {
189
+ // 34.21 — a file imported as text is its text, which the projection names apart.
190
+ if (name.endsWith(`${SOURCE_SUFFIX}.d.ts`))
191
+ return isFile(name.slice(0, -`${SOURCE_SUFFIX}.d.ts`.length))
192
+ ? 'declare const text: string\nexport default text\n'
193
+ : undefined
194
+ // 34.17 — WHAT A `.abide` EXPORTS, to anything importing it: the component its checked
195
+ // module declares, and nothing else. The checker resolves `./x.abide` by trying
196
+ // `./x.abide.d.ts`; the bindings the projection re-exports for the build (16.70) are not
197
+ // the importer's to read.
198
+ //
199
+ // 34.25 — AND THROUGH A PACKAGE'S `exports`, which the checker follows to the exact file and
200
+ // so asks for the arbitrary extension's declaration, `./x.d.abide.ts`, instead.
201
+ const abide = name.endsWith(DECLARATION_SUFFIX)
202
+ ? name.slice(0, -'.d.ts'.length)
203
+ : name.endsWith(EXPORTED_DECLARATION_SUFFIX)
204
+ ? `${name.slice(0, -EXPORTED_DECLARATION_SUFFIX.length)}.abide`
205
+ : undefined
206
+ if (abide !== undefined) {
207
+ if (!isFile(abide)) return undefined
208
+ const own = `./${basename(abide)}`
209
+ return `export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
210
+ `
211
+ }
212
+ // A projection not registered — a file with nothing to read, or one this session has not
213
+ // projected yet — exports nothing, so the declaration above always resolves.
214
+ if (name.endsWith(`.abide${SHADOW_SUFFIX}`))
215
+ return isFile(name.slice(0, -SHADOW_SUFFIX.length))
216
+ ? 'export {}\n'
217
+ : undefined
218
+ if (!name.endsWith(`.abide${CHECKED_SUFFIX}`)) return undefined
219
+ if (!isFile(name.slice(0, -CHECKED_SUFFIX.length))) return undefined
107
220
  this.stoodIn.add(this.keyOf(name))
108
221
  return COMPONENT_MODULE
109
222
  }
@@ -114,10 +227,13 @@ export class TypeSession {
114
227
  cwd: dirname(tsconfig),
115
228
  fs: {
116
229
  readFile: (name) =>
117
- session.shadows.get(session.keyOf(name))?.text ??
118
- session.standIn(name),
230
+ name === session.opened
231
+ ? session.sessionConfig()
232
+ : (session.shadowAt(name)?.text ??
233
+ session.standIn(name)),
119
234
  fileExists: (name) =>
120
- session.shadows.has(session.keyOf(name)) ||
235
+ name === session.opened ||
236
+ session.shadowAt(name) !== undefined ||
121
237
  session.standIn(name) !== undefined
122
238
  ? true
123
239
  : undefined,
@@ -183,6 +299,7 @@ export class TypeSession {
183
299
  // nobody's listing — so the checker never read it and the refusal below blamed the app's
184
300
  // tsconfig for a path this class had mangled.
185
301
  const here = this.keyOf(directory)
302
+ this.listed.add(here)
186
303
  const mine: string[] = []
187
304
  // The key is the checker's where it is registered, so only the incoming directory needs
188
305
  // keying — one `toPath` a call rather than one a shadow.
@@ -241,6 +358,7 @@ export class TypeSession {
241
358
  * checker is not told what was under it.
242
359
  */
243
360
  edited(paths: string[] | undefined): void {
361
+ this.walked = undefined
244
362
  if (paths === undefined) {
245
363
  this.everything = true
246
364
  return
@@ -265,11 +383,18 @@ export class TypeSession {
265
383
  }
266
384
  continue
267
385
  }
268
- if (directory) this.everything = true
386
+ // A FILE IN A DIRECTORY THE CHECKER HAS NEVER LISTED is a directory move the watcher
387
+ // reported as the file alone — a save that creates `late/` and the page in it
388
+ // at once — and the listing it already holds of the parent does not have it.
389
+ if (directory || !this.listed.has(this.keyOf(dirname(path))))
390
+ this.everything = true
269
391
  else this.edits.present.push(path)
270
392
  }
271
393
  }
272
394
 
395
+ /** Every directory the checker has asked this session to list (`entriesIn`). */
396
+ private listed = new Set<string>()
397
+
273
398
  /**
274
399
  * ONE SNAPSHOT TAKES EVERY EDIT WITH IT: the shadows written for it and whatever moved on disk.
275
400
  */
@@ -282,15 +407,21 @@ export class TypeSession {
282
407
  this.edits = { present: [], deleted: [] }
283
408
  this.everything = false
284
409
  this.snapshots += 1
410
+ // AN EDIT NOBODY COULD NAME DROPS THE CACHE, and the files created with it are still named
411
+ // after it: a dropped cache re-reads what the project already listed, and a shadow written
412
+ // for a page in a directory it never listed is not among them.
413
+ if (everything)
414
+ await this.api.updateSnapshot({
415
+ openProjects: [this.opened],
416
+ fileChanges: { invalidateAll: true },
417
+ })
285
418
  const snapshot = await this.api.updateSnapshot({
286
- openProjects: [this.tsconfig],
287
- fileChanges: everything
288
- ? { invalidateAll: true }
289
- : {
290
- created: [...created, ...edits.present],
291
- changed: [...changed, ...edits.present],
292
- deleted: edits.deleted,
293
- },
419
+ openProjects: [this.opened],
420
+ fileChanges: {
421
+ created: [...created, ...edits.present],
422
+ changed: [...changed, ...edits.present],
423
+ deleted: edits.deleted,
424
+ },
294
425
  })
295
426
  this.latest = snapshot.getProjects()[0]
296
427
  return this.latest
@@ -316,14 +447,49 @@ export class TypeSession {
316
447
  }
317
448
 
318
449
  /**
319
- * The project as of every edit so far, for a job to ask its questions of.
450
+ * 34.25, 23.19 — makes `path`, a dependency's file, a root of the opened config, telling
451
+ * `changed` the config moved where it was not one already.
452
+ */
453
+ private openOutside(path: string, changed: string[]): void {
454
+ if (this.outside.has(path)) return
455
+ this.outside.add(path)
456
+ if (!changed.includes(this.opened)) changed.push(this.opened)
457
+ }
458
+
459
+ /**
460
+ * The project, with each of `modules` outside the app's own a root of it.
320
461
  *
321
462
  * THE SNAPSHOT IS THE SESSION'S, AND A QUESTION IS ANY MODULE'S: what a handler declares
322
463
  * (`declaredExports`) is asked of the same snapshot the reads were, taken only where something
323
464
  * moved since.
465
+ *
466
+ * 23.19 — a handler file a mount places is a package's, which the app's `include` does not reach:
467
+ * what it exports is asked all the same (16.73), as a mounted `.abide`'s shadow is.
324
468
  */
325
- project(): Promise<Project | undefined> {
326
- return this.currentProject()
469
+ async projectWith(
470
+ modules: readonly string[],
471
+ ): Promise<Project | undefined> {
472
+ const changed: string[] = []
473
+ for (const path of modules) {
474
+ const canonical = this.canonical(path)
475
+ // The app's own is a root already: telling the config it moved reloads the project.
476
+ if (placeOf(this.belowRoot(canonical)) === 'dependency')
477
+ this.openOutside(canonical, changed)
478
+ }
479
+ const project = await this.currentProject([], changed)
480
+ if (project === undefined) return project
481
+ // A KEPT SESSION HEARS OF A FILE ONLY AS A WATCHER REPORTS IT, and one saved into a
482
+ // directory made since the last snapshot is reported as the directory: it is no root yet,
483
+ // and is told to the next snapshot as created.
484
+ const found = await Promise.all(
485
+ modules.map((path) => project.program.getSourceFile(path)),
486
+ )
487
+ const missing: string[] = []
488
+ for (let at = 0; at < modules.length; at += 1)
489
+ if (found[at] === undefined) missing.push(modules[at] as string)
490
+ return missing.length === 0
491
+ ? project
492
+ : this.currentProject(missing, [...missing])
327
493
  }
328
494
 
329
495
  /**
@@ -353,6 +519,15 @@ export class TypeSession {
353
519
  return resolve(dirname(this.tsconfig), path)
354
520
  }
355
521
 
522
+ /**
523
+ * Where the checker holds a `.abide`'s projection, once `readsFor` has registered it.
524
+ *
525
+ * 16.70 — what a `<script server>` binds is read off it as a `.ts` module's exports are.
526
+ */
527
+ projectionOf(abidePath: string): string {
528
+ return `${this.canonical(abidePath)}${SHADOW_SUFFIX}`
529
+ }
530
+
356
531
  /**
357
532
  * A file the checker reads out of `shadows`, reported to the next snapshot only when it is new,
358
533
  * its text moved, or a stand-in answered for it until now.
@@ -399,6 +574,7 @@ export class TypeSession {
399
574
  async readsFor(
400
575
  sources: readonly (Pick<ParsedSource, 'path' | 'file'> & {
401
576
  text?: string
577
+ position?: string | undefined
402
578
  })[],
403
579
  ): Promise<{ answers: CheckerAnswer[]; warnings: Diagnosis[] }> {
404
580
  const answered = await this.classify(sources)
@@ -421,6 +597,7 @@ export class TypeSession {
421
597
  private async classify(
422
598
  sources: readonly (Pick<ParsedSource, 'path' | 'file'> & {
423
599
  text?: string
600
+ position?: string | undefined
424
601
  })[],
425
602
  ): Promise<{ answers: CheckerAnswer[]; warnings: Diagnosis[] }> {
426
603
  const warnings: Diagnosis[] = []
@@ -443,8 +620,14 @@ export class TypeSession {
443
620
  // that scope on both sides. The projection holds both from offset 0.
444
621
  const scripts = projection.text.slice(0, projection.scripts.end)
445
622
  // A TEMPLATE POSITION IS A QUESTION TOO (14.2): a file that declares nothing but
446
- // writes `{format(total)}` has a position only the checker can prove static.
447
- if (!DECLARES.test(scripts) && projection.slots.size === 0) {
623
+ // writes `{format(total)}` has a position only the checker can prove static. And a
624
+ // `<script server>` always is (16.70): what it binds is read off this shadow, re-exported
625
+ // under `PRIVATE_EXPORT`.
626
+ if (
627
+ !DECLARES.test(scripts) &&
628
+ projection.slots.size === 0 &&
629
+ source.file.servers.length === 0
630
+ ) {
448
631
  queries.push(undefined)
449
632
  continue
450
633
  }
@@ -463,11 +646,14 @@ export class TypeSession {
463
646
  // AN UNCHANGED SHADOW IS NOT REPORTED, which is what a kept session saves on a save:
464
647
  // the one page that moved is re-read rather than all of them.
465
648
  this.register(map.shadowPath, projection.text, created, changed)
649
+ if (placeOf(this.belowRoot(source.path)) === 'dependency')
650
+ this.openOutside(map.shadowPath, changed)
466
651
  queries.push({
467
652
  map,
468
653
  spans: {
469
654
  scriptsEnd: projection.scripts.end,
470
655
  modules: projection.modules,
656
+ servers: projection.servers,
471
657
  scripts: projection.scripts,
472
658
  positions: projection.positions,
473
659
  },
@@ -504,7 +690,8 @@ export class TypeSession {
504
690
  sourceFile,
505
691
  query.map.shadowPath,
506
692
  query.spans,
507
- source.path,
693
+ // 23.19 — a mounted page's route is its position's.
694
+ source.position ?? this.belowRoot(source.path),
508
695
  source.file,
509
696
  )
510
697
  answers[at] = read.answer
@@ -634,7 +821,10 @@ export class TypeSession {
634
821
  * reported at its line — `abide check` emits the rest of the app without them.
635
822
  */
636
823
  async diagnose(
637
- sources: (Omit<ReadSource, 'quiet'> & { quiet?: boolean })[],
824
+ sources: (Omit<ReadSource, 'quiet' | 'position'> & {
825
+ quiet?: boolean
826
+ position?: string | undefined
827
+ })[],
638
828
  within: string,
639
829
  scripts = true,
640
830
  ): Promise<{ diagnoses: Diagnosis[]; refused: Set<string> }> {
@@ -664,12 +854,30 @@ export class TypeSession {
664
854
  created,
665
855
  changed,
666
856
  )
857
+ if (this.walked?.within !== within) {
858
+ // A refused tree is `checkApp`'s finding (38.33), never the checker's crash.
859
+ const files = appFiles(within, () => {})
860
+ this.walked = {
861
+ within,
862
+ routes: routesDeclaration(within, files),
863
+ configModules: configModulesUnder(within, files),
864
+ }
865
+ }
866
+ // 27.13 — what a handler file or a server module defaults, beside it.
867
+ for (const [path, text] of await this.walked.configModules) {
868
+ const shadow = moduleConfigShadow(text)
869
+ const at = `${this.canonical(path)}${CONFIG_SUFFIX}`
870
+ // One that stops filing keeps its place and declares nothing, so the next snapshot is
871
+ // told it moved.
872
+ if (shadow !== undefined || this.shadows.has(this.keyOf(at)))
873
+ this.register(at, shadow ?? 'export {}\n', created, changed)
874
+ }
667
875
  // 30.26 — THE APP'S ROUTE TABLE, which `url`'s literal is held to: every page under the
668
876
  // root, declared into abide's own `Routes`. A module rather than a script, since only a
669
877
  // module augments one.
670
878
  this.register(
671
879
  `${this.canonical(within)}/${ROUTES_DECLARATION}`,
672
- routesDeclaration(within),
880
+ this.walked.routes,
673
881
  created,
674
882
  changed,
675
883
  )
@@ -749,10 +957,58 @@ export class TypeSession {
749
957
  return { diagnoses: found, refused: refusing }
750
958
  }
751
959
 
960
+ /**
961
+ * Where the author's `offset` in a `.abide` stands in what the checker reads, and the program
962
+ * `diagnose` last lowered it to — or `undefined` where `text` is not what was lowered.
963
+ */
964
+ private askedAt(
965
+ path: string,
966
+ text: string,
967
+ offset: number,
968
+ ):
969
+ | { project: Project; asked: { path: string; position: number } }
970
+ | undefined {
971
+ const lowered = this.loweredForInspect.get(this.keyOf(path))
972
+ const project = this.latest
973
+ if (
974
+ lowered === undefined ||
975
+ lowered.text !== text ||
976
+ project === undefined
977
+ )
978
+ return undefined
979
+ const asked = lowered.map.fromSource(offset)
980
+ if (asked === undefined || !this.shadows.has(this.keyOf(asked.path)))
981
+ return undefined
982
+ return { project, asked }
983
+ }
984
+
985
+ /**
986
+ * The members that may be written at `offset` in a `.abide`, or `undefined` where none can be.
987
+ *
988
+ * 38.98 — ASKED WHERE A HOVER IS (`askedAt`), so `config().` offers the fields 27.13 declared and
989
+ * a name read by name offers its value's members (31.1). MEMBERS ALONE: at an identifier the
990
+ * API rejects ("completion list needs auto imports"), which the language server answers as
991
+ * nothing offered.
992
+ */
993
+ async complete(
994
+ path: string,
995
+ text: string,
996
+ offset: number,
997
+ triggerCharacter?: string,
998
+ ): Promise<CompletionInfo | undefined> {
999
+ const at = this.askedAt(path, text, offset)
1000
+ if (at === undefined) return undefined
1001
+ return at.project.checker.getCompletionsAtPosition(
1002
+ at.asked.path,
1003
+ at.asked.position,
1004
+ { triggerCharacter },
1005
+ )
1006
+ }
1007
+
752
1008
  /**
753
1009
  * The hover signature, documentation and definitions of the name at `offset` in a `.abide`.
754
1010
  *
755
- * 38.40 — WHAT THE NAME AT `offset` IN A `.abide` IS, asked of the program `diagnose` last lowered
1011
+ * 38.39 — WHAT THE NAME AT `offset` IN A `.abide` IS, asked of the program `diagnose` last lowered
756
1012
  * it to: its type and documentation for a hover, and where it is declared for a jump. The
757
1013
  * checked module is asked where the position was copied into it, which is what a member hangs
758
1014
  * off — `regions.join` is `Array`'s — and the projection where the lowering rewrote it, so a
@@ -767,17 +1023,9 @@ export class TypeSession {
767
1023
  text: string,
768
1024
  offset: number,
769
1025
  ): Promise<Inspection | undefined> {
770
- const lowered = this.loweredForInspect.get(this.keyOf(path))
771
- const project = this.latest
772
- if (
773
- lowered === undefined ||
774
- lowered.text !== text ||
775
- project === undefined
776
- )
777
- return undefined
778
- const asked = lowered.map.fromSource(offset)
779
- if (asked === undefined || !this.shadows.has(this.keyOf(asked.path)))
780
- return undefined
1026
+ const at = this.askedAt(path, text, offset)
1027
+ if (at === undefined) return undefined
1028
+ const { project, asked } = at
781
1029
  const checker = project.checker
782
1030
  const found = await checker.getSymbolAtPosition(
783
1031
  asked.path,
@@ -888,44 +1136,43 @@ export class TypeSession {
888
1136
  }
889
1137
 
890
1138
  const AMBIENT = 'abide-check.d.ts'
1139
+
1140
+ // What the checker asks for when it resolves an import of `x.abide` (`standIn`).
1141
+ const DECLARATION_SUFFIX = '.abide.d.ts'
1142
+ // Beside the app's tsconfig, so `extends` and every path in it resolve as they would from there.
1143
+ const SESSION_CONFIG = 'tsconfig.abide-session.json'
1144
+ const EXPORTED_DECLARATION_SUFFIX = '.d.abide.ts'
1145
+
1146
+ function isFile(path: string): boolean {
1147
+ try {
1148
+ return statSync(path).isFile()
1149
+ } catch {
1150
+ return false
1151
+ }
1152
+ }
891
1153
  const ROUTES_DECLARATION = 'abide-routes.d.ts'
892
1154
  const URL_SOURCE = Bun.fileURLToPath(new URL('../shared/url', import.meta.url))
893
1155
 
894
- /** 30.26 — every page under `root` as a key of abide's `Routes`, or nothing where it has none. */
895
- function routesDeclaration(root: string): string {
1156
+ /**
1157
+ * 30.26 — every page under `root`, and every public file's address, as a key of abide's `Routes`, or
1158
+ * nothing where it has neither.
1159
+ */
1160
+ function routesDeclaration(
1161
+ root: string,
1162
+ { abide, published }: ReturnType<typeof appFiles>,
1163
+ ): string {
896
1164
  const routes = new Set<string>()
897
- routesUnder(root, '', routes)
1165
+ for (const path of abide)
1166
+ if (fileKind(path) === 'page') routes.add(routeOf(path))
1167
+ // 23.19 — and every page a mount places in the app, at its position.
1168
+ for (const { position } of mountedUnder(root, abide))
1169
+ if (fileKind(position) === 'page') routes.add(routeOf(position))
1170
+ for (const address of published.values()) routes.add(address)
898
1171
  let keys = ''
899
1172
  for (const route of [...routes].sort())
900
1173
  keys += ` ${JSON.stringify(route)}: true\n`
901
1174
  return `export {}\ndeclare module ${JSON.stringify(URL_SOURCE)} {\n interface Routes {\n${keys} }\n}\n`
902
1175
  }
903
- /**
904
- * The routes of the pages under `directory`, added to `routes`.
905
- *
906
- * A WALK RATHER THAN A GLOB, because it runs on every check and a glob descends `node_modules` and
907
- * the build before a filter can drop them — 15.6 ms against 5.7 from this repo's root, same pages.
908
- */
909
- function routesUnder(
910
- directory: string,
911
- relative: string,
912
- routes: Set<string>,
913
- ): void {
914
- let entries: Dirent[]
915
- try {
916
- entries = readdirSync(directory, { withFileTypes: true })
917
- } catch {
918
- return
919
- }
920
- for (const entry of entries) {
921
- if (entry.name.startsWith('.') || entry.name === 'node_modules')
922
- continue
923
- const path = `${relative}${entry.name}`
924
- if (entry.isDirectory())
925
- routesUnder(join(directory, entry.name), `${path}/`, routes)
926
- else if (fileKind(path) === 'page') routes.add(routeOf(path))
927
- }
928
- }
929
1176
  const AMBIENT_DECLARATIONS = `declare module '*.css' {}
930
1177
  `
931
1178
 
@@ -946,7 +1193,7 @@ export type Diagnosis = {
946
1193
  /**
947
1194
  * Where something is in a file the author wrote, line and column from 1.
948
1195
  *
949
- * 38.40.
1196
+ * 38.39.
950
1197
  */
951
1198
  export type Place = {
952
1199
  path: string
@@ -957,7 +1204,7 @@ export type Place = {
957
1204
  /**
958
1205
  * The name under a cursor: how it is declared, what its doc comment says, and where.
959
1206
  *
960
- * 38.40.
1207
+ * 38.39.
961
1208
  */
962
1209
  export type Inspection = {
963
1210
  signature: string
@@ -1077,3 +1324,26 @@ function flatten(diagnostic: {
1077
1324
  message += `\n ${flatten(link as Parameters<typeof flatten>[0]).replaceAll('\n', '\n ')}`
1078
1325
  return message
1079
1326
  }
1327
+
1328
+ /** What a module that is no `.abide` is checked beside as, for the `Config` it files (27.13). */
1329
+ const CONFIG_SUFFIX = '.config-shadow.ts'
1330
+
1331
+ /**
1332
+ * Every handler file and every module under a `server` directory below `within` that names
1333
+ * `onConfig`, with its text — the modules a server runs that 27.13 types `Config` off.
1334
+ */
1335
+ async function configModulesUnder(
1336
+ within: string,
1337
+ { handlers, modules }: ReturnType<typeof appFiles>,
1338
+ ): Promise<[string, string][]> {
1339
+ const paths: string[] = []
1340
+ for (const name of handlers) paths.push(`${within}/${name}`)
1341
+ for (const name of modules)
1342
+ if (placeOf(name) === 'server') paths.push(`${within}/${name}`)
1343
+ const texts = await Promise.all(paths.map((path) => Bun.file(path).text()))
1344
+ const found: [string, string][] = []
1345
+ for (let at = 0; at < paths.length; at += 1)
1346
+ if ((texts[at] as string).includes('onConfig'))
1347
+ found.push([paths[at] as string, texts[at] as string])
1348
+ return found
1349
+ }