@abide/abide 0.101.0 → 0.102.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 (157) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/abide.ts +223 -164
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +5 -3
  7. package/scaffold/AGENTS.md +31 -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 +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -59
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +102 -30
  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 +179 -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 +37 -37
  65. package/src/server/componentModules.d.ts +3 -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 +59 -73
  70. package/src/server/devWorker.ts +114 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -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 +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +73 -37
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  107. package/src/shared/cookies.ts +2 -2
  108. package/src/shared/csp.ts +1 -1
  109. package/src/shared/escapeText.ts +1 -1
  110. package/src/shared/filledPath.ts +85 -0
  111. package/src/shared/framing.ts +79 -6
  112. package/src/shared/headKeyOf.ts +2 -0
  113. package/src/shared/index.ts +1 -1
  114. package/src/shared/log.ts +27 -13
  115. package/src/shared/matchRoute.ts +23 -4
  116. package/src/shared/menuItem.ts +3 -3
  117. package/src/shared/reactive/Keyed.ts +2 -2
  118. package/src/shared/reactive/NotLanded.ts +1 -1
  119. package/src/shared/reactive/Reactive.ts +43 -43
  120. package/src/shared/reactive/argsContract.ts +4 -4
  121. package/src/shared/reactive/canonicalKey.ts +4 -4
  122. package/src/shared/reactive/chunksOfStream.ts +6 -0
  123. package/src/shared/reactive/index.ts +1 -1
  124. package/src/shared/reactive/recordWrites.ts +103 -5
  125. package/src/shared/reactive/structural.ts +1 -1
  126. package/src/shared/reactive/tracking.ts +1 -1
  127. package/src/shared/reactive/watch.ts +3 -3
  128. package/src/shared/refuse.ts +1 -1
  129. package/src/shared/routePattern.ts +82 -46
  130. package/src/shared/rpc.ts +37 -8
  131. package/src/shared/rpcRequest.ts +27 -3
  132. package/src/shared/url.ts +4 -44
  133. package/src/shared/writePath.ts +1 -1
  134. package/src/ui/LAYER_NAMES.ts +1 -1
  135. package/src/ui/List.ts +3 -3
  136. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  137. package/src/ui/Mismatch.ts +1 -1
  138. package/src/ui/RawRun.ts +1 -1
  139. package/src/ui/Router.ts +38 -31
  140. package/src/ui/Scope.ts +1 -1
  141. package/src/ui/TryRegion.ts +2 -2
  142. package/src/ui/applyHead.ts +33 -0
  143. package/src/ui/index.ts +1 -1
  144. package/src/ui/menuBridge.ts +1 -1
  145. package/src/ui/moveNode.ts +1 -1
  146. package/src/ui/reconcile.ts +2 -2
  147. package/src/ui/reportMenu.ts +2 -2
  148. package/src/ui/routing.ts +2 -2
  149. package/src/ui/setAttribute.ts +1 -1
  150. package/src/ui/setText.ts +1 -1
  151. package/src/ui/socketWire.ts +9 -7
  152. package/src/ui/wire.ts +1 -1
  153. package/scaffold/src/server/app.ts +0 -6
  154. package/scaffold/src/shared/.gitkeep +0 -0
  155. package/scaffold/src/ui/app.html +0 -8
  156. package/src/server/componentImports.ts +0 -56
  157. /package/scaffold/src/{ui/pages/page.abide → page.abide} +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'
@@ -23,12 +25,14 @@ import {
23
25
  import type { Finding } from './Finding.ts'
24
26
  import { CHECKED_VALUES } from './Lowering.ts'
25
27
  import { diagnosisAt, lineOf } from './lineOf.ts'
28
+ import { mountedUnder } from './mountsOf.ts'
26
29
  import type { AbideFile } from './parseAbide.ts'
27
30
  import { projectShadow } from './projectShadow.ts'
28
31
  import { abideIn } from './reactiveTypes.ts'
29
32
  import type { ParsedSource, ReadSource } from './readSources.ts'
30
33
  import { readsIn, type ShadowSpans } from './readsIn.ts'
31
- import { CHECKED_SUFFIX, ShadowMap } from './ShadowMap.ts'
34
+ import { CHECKED_SUFFIX, SHADOW_SUFFIX, ShadowMap } from './ShadowMap.ts'
35
+ import { SOURCE_SUFFIX } from './splitImports.ts'
32
36
  import { templateWarnings } from './templateWarnings.ts'
33
37
 
34
38
  // Where 31.3 gets its answer. A `.abide` file is projected into a SHADOW `.ts` held in a virtual
@@ -60,6 +64,13 @@ export class TypeSession {
60
64
  * text's offsets.
61
65
  */
62
66
  private shadows = new Map<string, { path: string; text: string }>()
67
+ /**
68
+ * 30.26 — the route table `diagnose` last declared, and the root it walked for it: kept until
69
+ * `edited` hears anything move, so a check asked again and again while an editor types walks and
70
+ * reads nothing on disk.
71
+ */
72
+ private routes: { within: string; declaration: string } | undefined =
73
+ undefined
63
74
  /** The checker's own key for a path: resolved, and case-folded where its host folds case. */
64
75
  private keyOf: (path: string) => string
65
76
  /**
@@ -83,27 +94,124 @@ export class TypeSession {
83
94
  ) {
84
95
  this.api = api
85
96
  this.tsconfig = tsconfig
97
+ this.opened = `${dirname(tsconfig)}/${SESSION_CONFIG}`
86
98
  this.keyOf = keyOf
87
99
  }
88
100
 
101
+ /**
102
+ * The project this session opens: the app's tsconfig, with every shadow in `outside` a root.
103
+ *
104
+ * 34.29 — A DEPENDENCY'S `.abide` IS TYPED AS THE APP'S OWN, and its tsconfig's `include` never
105
+ * reaches `node_modules` or a linked package's directory. The session opens a config of its own —
106
+ * served from here, never written — that extends the app's, names those shadows as files, which
107
+ * TypeScript adds to what the base config includes (a linked package's shadow, asked for through
108
+ * the link, is reached no other way), and allows the declaration a package's `exports` is asked
109
+ * for by (`standIn`).
110
+ */
111
+ private readonly opened: string
112
+
113
+ /** The shadows of the files outside the app's own, as `opened` names them. */
114
+ private outside = new Set<string>()
115
+
116
+ private sessionConfig(): string {
117
+ const config: {
118
+ extends: string
119
+ compilerOptions: Record<string, unknown>
120
+ files?: string[]
121
+ } = {
122
+ extends: `./${basename(this.tsconfig)}`,
123
+ // A package's `exports` names `./x.abide` exactly, so the checker asks for its
124
+ // declaration as `./x.d.abide.ts` (`standIn`) — and takes the answer only with this.
125
+ compilerOptions: { allowArbitraryExtensions: true },
126
+ }
127
+ // An empty `files` is a config error, and the base's `include` alone is the app.
128
+ if (this.outside.size > 0) config.files = [...this.outside]
129
+ return JSON.stringify(config)
130
+ }
131
+
89
132
  /**
90
133
  * Checked-module paths a stand-in answered for, without the `.abide` being lowered.
91
134
  *
92
- * 38.40 — A `.abide` THIS SESSION HAS NOT LOWERED IS STILL A MODULE ANOTHER CAN IMPORT. What its
135
+ * 38.39 — A `.abide` THIS SESSION HAS NOT LOWERED IS STILL A MODULE ANOTHER CAN IMPORT. What its
93
136
  * checked module exports does not depend on what the component holds (`COMPONENT_MODULE`), so an
94
137
  * editor checking one page resolves the components it imports without lowering each of them.
95
138
  * Remembered, so the lowering that replaces a stand-in is reported as a change to it.
96
139
  */
97
140
  private stoodIn = new Set<string>()
98
141
 
99
- private standIn(name: string): string | undefined {
100
- if (!name.endsWith(`.abide${CHECKED_SUFFIX}`)) return undefined
142
+ /** The app's root, real — set by `openChecker`. */
143
+ root = ''
144
+
145
+ /**
146
+ * `path` below the app's root, where it lies under it.
147
+ *
148
+ * 23.14 — what a page's route is read off: the directories below the root are it, and the
149
+ * machine's directories above the root are not.
150
+ */
151
+ belowRoot(path: string): string {
152
+ return path.startsWith(`${this.root}/`)
153
+ ? path.slice(this.root.length + 1)
154
+ : path
155
+ }
156
+
157
+ /**
158
+ * The shadow registered at `name`, or at the same file through its directory's real path.
159
+ *
160
+ * 34.29 — A LINKED PACKAGE IS ASKED FOR THROUGH ITS LINK: the checker resolves `kit/x.abide` to
161
+ * `node_modules/kit/x.d.abide.ts` and the re-export there to the shadow beside it, while the
162
+ * shadow is registered where bun resolved the file — the package's real directory.
163
+ */
164
+ private shadowAt(name: string): { path: string; text: string } | undefined {
165
+ const found = this.shadows.get(this.keyOf(name))
166
+ // Only a shadow is registered through a link, so any other name is spared the realpath.
167
+ if (
168
+ found !== undefined ||
169
+ !name.includes('/node_modules/') ||
170
+ !(name.endsWith(SHADOW_SUFFIX) || name.endsWith(CHECKED_SUFFIX))
171
+ )
172
+ return found
173
+ let real: string
101
174
  try {
102
- if (!statSync(name.slice(0, -CHECKED_SUFFIX.length)).isFile())
103
- return undefined
175
+ real = realpathSync(dirname(name))
104
176
  } catch {
105
177
  return undefined
106
178
  }
179
+ return this.shadows.get(this.keyOf(`${real}/${basename(name)}`))
180
+ }
181
+
182
+ private standIn(name: string): string | undefined {
183
+ // 34.25 — a file imported as text is its text, which the projection names apart.
184
+ if (name.endsWith(`${SOURCE_SUFFIX}.d.ts`))
185
+ return isFile(name.slice(0, -`${SOURCE_SUFFIX}.d.ts`.length))
186
+ ? 'declare const text: string\nexport default text\n'
187
+ : undefined
188
+ // 34.17, 34.23 — WHAT A `.abide` EXPORTS BY NAME, to anything importing it: the checker
189
+ // resolves `./x.abide` by trying `./x.abide.d.ts`, and is answered with the file's own
190
+ // module and server blocks — its projection — beside the component its checked module
191
+ // declares. The wildcard declaration a plain `.ts` is typed by carries the default alone.
192
+ //
193
+ // 34.29 — AND THROUGH A PACKAGE'S `exports`, which the checker follows to the exact file and
194
+ // so asks for the arbitrary extension's declaration, `./x.d.abide.ts`, instead.
195
+ const abide = name.endsWith(DECLARATION_SUFFIX)
196
+ ? name.slice(0, -'.d.ts'.length)
197
+ : name.endsWith(EXPORTED_DECLARATION_SUFFIX)
198
+ ? `${name.slice(0, -EXPORTED_DECLARATION_SUFFIX.length)}.abide`
199
+ : undefined
200
+ if (abide !== undefined) {
201
+ if (!isFile(abide)) return undefined
202
+ const own = `./${basename(abide)}`
203
+ return `export * from '${own}${SHADOW_SUFFIX.slice(0, -'.ts'.length)}'
204
+ export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
205
+ `
206
+ }
207
+ // A projection not registered — a file with nothing to read, or one this session has not
208
+ // projected yet — exports nothing, so the declaration above always resolves.
209
+ if (name.endsWith(`.abide${SHADOW_SUFFIX}`))
210
+ return isFile(name.slice(0, -SHADOW_SUFFIX.length))
211
+ ? 'export {}\n'
212
+ : undefined
213
+ if (!name.endsWith(`.abide${CHECKED_SUFFIX}`)) return undefined
214
+ if (!isFile(name.slice(0, -CHECKED_SUFFIX.length))) return undefined
107
215
  this.stoodIn.add(this.keyOf(name))
108
216
  return COMPONENT_MODULE
109
217
  }
@@ -114,10 +222,13 @@ export class TypeSession {
114
222
  cwd: dirname(tsconfig),
115
223
  fs: {
116
224
  readFile: (name) =>
117
- session.shadows.get(session.keyOf(name))?.text ??
118
- session.standIn(name),
225
+ name === session.opened
226
+ ? session.sessionConfig()
227
+ : (session.shadowAt(name)?.text ??
228
+ session.standIn(name)),
119
229
  fileExists: (name) =>
120
- session.shadows.has(session.keyOf(name)) ||
230
+ name === session.opened ||
231
+ session.shadowAt(name) !== undefined ||
121
232
  session.standIn(name) !== undefined
122
233
  ? true
123
234
  : undefined,
@@ -183,6 +294,7 @@ export class TypeSession {
183
294
  // nobody's listing — so the checker never read it and the refusal below blamed the app's
184
295
  // tsconfig for a path this class had mangled.
185
296
  const here = this.keyOf(directory)
297
+ this.listed.add(here)
186
298
  const mine: string[] = []
187
299
  // The key is the checker's where it is registered, so only the incoming directory needs
188
300
  // keying — one `toPath` a call rather than one a shadow.
@@ -241,6 +353,7 @@ export class TypeSession {
241
353
  * checker is not told what was under it.
242
354
  */
243
355
  edited(paths: string[] | undefined): void {
356
+ this.routes = undefined
244
357
  if (paths === undefined) {
245
358
  this.everything = true
246
359
  return
@@ -265,11 +378,18 @@ export class TypeSession {
265
378
  }
266
379
  continue
267
380
  }
268
- if (directory) this.everything = true
381
+ // A FILE IN A DIRECTORY THE CHECKER HAS NEVER LISTED is a directory move the watcher
382
+ // reported as the file alone — a save that creates `late/` and the page in it
383
+ // at once — and the listing it already holds of the parent does not have it.
384
+ if (directory || !this.listed.has(this.keyOf(dirname(path))))
385
+ this.everything = true
269
386
  else this.edits.present.push(path)
270
387
  }
271
388
  }
272
389
 
390
+ /** Every directory the checker has asked this session to list (`entriesIn`). */
391
+ private listed = new Set<string>()
392
+
273
393
  /**
274
394
  * ONE SNAPSHOT TAKES EVERY EDIT WITH IT: the shadows written for it and whatever moved on disk.
275
395
  */
@@ -282,15 +402,21 @@ export class TypeSession {
282
402
  this.edits = { present: [], deleted: [] }
283
403
  this.everything = false
284
404
  this.snapshots += 1
405
+ // AN EDIT NOBODY COULD NAME DROPS THE CACHE, and the files created with it are still named
406
+ // after it: a dropped cache re-reads what the project already listed, and a shadow written
407
+ // for a page in a directory it never listed is not among them.
408
+ if (everything)
409
+ await this.api.updateSnapshot({
410
+ openProjects: [this.opened],
411
+ fileChanges: { invalidateAll: true },
412
+ })
285
413
  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
- },
414
+ openProjects: [this.opened],
415
+ fileChanges: {
416
+ created: [...created, ...edits.present],
417
+ changed: [...changed, ...edits.present],
418
+ deleted: edits.deleted,
419
+ },
294
420
  })
295
421
  this.latest = snapshot.getProjects()[0]
296
422
  return this.latest
@@ -353,6 +479,15 @@ export class TypeSession {
353
479
  return resolve(dirname(this.tsconfig), path)
354
480
  }
355
481
 
482
+ /**
483
+ * Where the checker holds a `.abide`'s projection, once `readsFor` has registered it.
484
+ *
485
+ * 19.15 — what a `<script server>` exports is read off it as a `.ts` module's exports are.
486
+ */
487
+ projectionOf(abidePath: string): string {
488
+ return `${this.canonical(abidePath)}${SHADOW_SUFFIX}`
489
+ }
490
+
356
491
  /**
357
492
  * A file the checker reads out of `shadows`, reported to the next snapshot only when it is new,
358
493
  * its text moved, or a stand-in answered for it until now.
@@ -399,6 +534,7 @@ export class TypeSession {
399
534
  async readsFor(
400
535
  sources: readonly (Pick<ParsedSource, 'path' | 'file'> & {
401
536
  text?: string
537
+ position?: string | undefined
402
538
  })[],
403
539
  ): Promise<{ answers: CheckerAnswer[]; warnings: Diagnosis[] }> {
404
540
  const answered = await this.classify(sources)
@@ -421,6 +557,7 @@ export class TypeSession {
421
557
  private async classify(
422
558
  sources: readonly (Pick<ParsedSource, 'path' | 'file'> & {
423
559
  text?: string
560
+ position?: string | undefined
424
561
  })[],
425
562
  ): Promise<{ answers: CheckerAnswer[]; warnings: Diagnosis[] }> {
426
563
  const warnings: Diagnosis[] = []
@@ -443,8 +580,14 @@ export class TypeSession {
443
580
  // that scope on both sides. The projection holds both from offset 0.
444
581
  const scripts = projection.text.slice(0, projection.scripts.end)
445
582
  // 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) {
583
+ // writes `{format(total)}` has a position only the checker can prove static. And a
584
+ // `<script server>` always is (19.15): what it exports is read off this shadow, a bare
585
+ // `export { touch } from …` included.
586
+ if (
587
+ !DECLARES.test(scripts) &&
588
+ projection.slots.size === 0 &&
589
+ source.file.servers.length === 0
590
+ ) {
448
591
  queries.push(undefined)
449
592
  continue
450
593
  }
@@ -463,11 +606,19 @@ export class TypeSession {
463
606
  // AN UNCHANGED SHADOW IS NOT REPORTED, which is what a kept session saves on a save:
464
607
  // the one page that moved is re-read rather than all of them.
465
608
  this.register(map.shadowPath, projection.text, created, changed)
609
+ if (
610
+ placeOf(this.belowRoot(source.path)) === 'dependency' &&
611
+ !this.outside.has(map.shadowPath)
612
+ ) {
613
+ this.outside.add(map.shadowPath)
614
+ changed.push(this.opened)
615
+ }
466
616
  queries.push({
467
617
  map,
468
618
  spans: {
469
619
  scriptsEnd: projection.scripts.end,
470
620
  modules: projection.modules,
621
+ servers: projection.servers,
471
622
  scripts: projection.scripts,
472
623
  positions: projection.positions,
473
624
  },
@@ -504,7 +655,8 @@ export class TypeSession {
504
655
  sourceFile,
505
656
  query.map.shadowPath,
506
657
  query.spans,
507
- source.path,
658
+ // 23.19 — a mounted page's route is its position's.
659
+ source.position ?? this.belowRoot(source.path),
508
660
  source.file,
509
661
  )
510
662
  answers[at] = read.answer
@@ -634,7 +786,10 @@ export class TypeSession {
634
786
  * reported at its line — `abide check` emits the rest of the app without them.
635
787
  */
636
788
  async diagnose(
637
- sources: (Omit<ReadSource, 'quiet'> & { quiet?: boolean })[],
789
+ sources: (Omit<ReadSource, 'quiet' | 'position'> & {
790
+ quiet?: boolean
791
+ position?: string | undefined
792
+ })[],
638
793
  within: string,
639
794
  scripts = true,
640
795
  ): Promise<{ diagnoses: Diagnosis[]; refused: Set<string> }> {
@@ -667,9 +822,11 @@ export class TypeSession {
667
822
  // 30.26 — THE APP'S ROUTE TABLE, which `url`'s literal is held to: every page under the
668
823
  // root, declared into abide's own `Routes`. A module rather than a script, since only a
669
824
  // module augments one.
825
+ if (this.routes?.within !== within)
826
+ this.routes = { within, declaration: routesDeclaration(within) }
670
827
  this.register(
671
828
  `${this.canonical(within)}/${ROUTES_DECLARATION}`,
672
- routesDeclaration(within),
829
+ this.routes.declaration,
673
830
  created,
674
831
  changed,
675
832
  )
@@ -749,10 +906,58 @@ export class TypeSession {
749
906
  return { diagnoses: found, refused: refusing }
750
907
  }
751
908
 
909
+ /**
910
+ * Where the author's `offset` in a `.abide` stands in what the checker reads, and the program
911
+ * `diagnose` last lowered it to — or `undefined` where `text` is not what was lowered.
912
+ */
913
+ private askedAt(
914
+ path: string,
915
+ text: string,
916
+ offset: number,
917
+ ):
918
+ | { project: Project; asked: { path: string; position: number } }
919
+ | undefined {
920
+ const lowered = this.loweredForInspect.get(this.keyOf(path))
921
+ const project = this.latest
922
+ if (
923
+ lowered === undefined ||
924
+ lowered.text !== text ||
925
+ project === undefined
926
+ )
927
+ return undefined
928
+ const asked = lowered.map.fromSource(offset)
929
+ if (asked === undefined || !this.shadows.has(this.keyOf(asked.path)))
930
+ return undefined
931
+ return { project, asked }
932
+ }
933
+
934
+ /**
935
+ * The members that may be written at `offset` in a `.abide`, or `undefined` where none can be.
936
+ *
937
+ * 38.98 — ASKED WHERE A HOVER IS (`askedAt`), so `config().` offers the fields 27.13 declared and
938
+ * a name read by name offers its value's members (31.1). MEMBERS ALONE: at an identifier the
939
+ * API rejects ("completion list needs auto imports"), which the language server answers as
940
+ * nothing offered.
941
+ */
942
+ async complete(
943
+ path: string,
944
+ text: string,
945
+ offset: number,
946
+ triggerCharacter?: string,
947
+ ): Promise<CompletionInfo | undefined> {
948
+ const at = this.askedAt(path, text, offset)
949
+ if (at === undefined) return undefined
950
+ return at.project.checker.getCompletionsAtPosition(
951
+ at.asked.path,
952
+ at.asked.position,
953
+ { triggerCharacter },
954
+ )
955
+ }
956
+
752
957
  /**
753
958
  * The hover signature, documentation and definitions of the name at `offset` in a `.abide`.
754
959
  *
755
- * 38.40 — WHAT THE NAME AT `offset` IN A `.abide` IS, asked of the program `diagnose` last lowered
960
+ * 38.39 — WHAT THE NAME AT `offset` IN A `.abide` IS, asked of the program `diagnose` last lowered
756
961
  * it to: its type and documentation for a hover, and where it is declared for a jump. The
757
962
  * checked module is asked where the position was copied into it, which is what a member hangs
758
963
  * off — `regions.join` is `Array`'s — and the projection where the lowering rewrote it, so a
@@ -767,17 +972,9 @@ export class TypeSession {
767
972
  text: string,
768
973
  offset: number,
769
974
  ): 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
975
+ const at = this.askedAt(path, text, offset)
976
+ if (at === undefined) return undefined
977
+ const { project, asked } = at
781
978
  const checker = project.checker
782
979
  const found = await checker.getSymbolAtPosition(
783
980
  asked.path,
@@ -888,44 +1085,42 @@ export class TypeSession {
888
1085
  }
889
1086
 
890
1087
  const AMBIENT = 'abide-check.d.ts'
1088
+
1089
+ // What the checker asks for when it resolves an import of `x.abide` (`standIn`).
1090
+ const DECLARATION_SUFFIX = '.abide.d.ts'
1091
+ // Beside the app's tsconfig, so `extends` and every path in it resolve as they would from there.
1092
+ const SESSION_CONFIG = 'tsconfig.abide-session.json'
1093
+ const EXPORTED_DECLARATION_SUFFIX = '.d.abide.ts'
1094
+
1095
+ function isFile(path: string): boolean {
1096
+ try {
1097
+ return statSync(path).isFile()
1098
+ } catch {
1099
+ return false
1100
+ }
1101
+ }
891
1102
  const ROUTES_DECLARATION = 'abide-routes.d.ts'
892
1103
  const URL_SOURCE = Bun.fileURLToPath(new URL('../shared/url', import.meta.url))
893
1104
 
894
- /** 30.26 — every page under `root` as a key of abide's `Routes`, or nothing where it has none. */
1105
+ /**
1106
+ * 30.26 — every page under `root`, and every public file's address, as a key of abide's `Routes`, or
1107
+ * nothing where it has neither.
1108
+ */
895
1109
  function routesDeclaration(root: string): string {
896
1110
  const routes = new Set<string>()
897
- routesUnder(root, '', routes)
1111
+ // A refused tree is `checkApp`'s finding (38.33), never the checker's crash.
1112
+ const { abide, published } = appFiles(root, () => {})
1113
+ for (const path of abide)
1114
+ if (fileKind(path) === 'page') routes.add(routeOf(path))
1115
+ // 23.19 — and every page a mount places in the app, at its position.
1116
+ for (const { position } of mountedUnder(root, abide))
1117
+ if (fileKind(position) === 'page') routes.add(routeOf(position))
1118
+ for (const address of published.values()) routes.add(address)
898
1119
  let keys = ''
899
1120
  for (const route of [...routes].sort())
900
1121
  keys += ` ${JSON.stringify(route)}: true\n`
901
1122
  return `export {}\ndeclare module ${JSON.stringify(URL_SOURCE)} {\n interface Routes {\n${keys} }\n}\n`
902
1123
  }
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
1124
  const AMBIENT_DECLARATIONS = `declare module '*.css' {}
930
1125
  `
931
1126
 
@@ -946,7 +1141,7 @@ export type Diagnosis = {
946
1141
  /**
947
1142
  * Where something is in a file the author wrote, line and column from 1.
948
1143
  *
949
- * 38.40.
1144
+ * 38.39.
950
1145
  */
951
1146
  export type Place = {
952
1147
  path: string
@@ -957,7 +1152,7 @@ export type Place = {
957
1152
  /**
958
1153
  * The name under a cursor: how it is declared, what its doc comment says, and where.
959
1154
  *
960
- * 38.40.
1155
+ * 38.39.
961
1156
  */
962
1157
  export type Inspection = {
963
1158
  signature: string
@@ -134,7 +134,7 @@ async function objectOf(
134
134
  }
135
135
  const schema: Schema = { type: 'object', properties }
136
136
  if (required.length > 0) schema.required = required
137
- // 19.20 — A MEMBER THE TYPE DOES NOT DECLARE IS REFUSED, where no index signature declares the
137
+ // 19.19 — A MEMBER THE TYPE DOES NOT DECLARE IS REFUSED, where no index signature declares the
138
138
  // rest: a caller outside the process sends whatever it likes, and a body writing its args to a
139
139
  // row would write that too. An author who wants the rest declares a schema of their own.
140
140
  schema.additionalProperties = false
@@ -1,11 +1,13 @@
1
1
  import { relative } from 'node:path' // bun exposes no path helpers of its own
2
- import { buildDirectory } from '#server/buildDirectory.ts'
2
+ import { appFiles } from '#server/appTree.ts'
3
+ import { placeOf } from '#shared/routePattern.ts'
3
4
  import {
4
5
  type AppChecker,
5
6
  emitApp,
6
7
  resolverIn,
7
8
  sourcesUnder,
8
9
  withChecker,
10
+ withDependencies,
9
11
  } from './compileApp.ts'
10
12
  import { abideImportsOf } from './componentGraph.ts'
11
13
  import {
@@ -15,6 +17,7 @@ import {
15
17
  refusal,
16
18
  } from './readSources.ts'
17
19
  import type { Diagnosis } from './TypeSession.ts'
20
+ import { writeTypes } from './writeTypes.ts'
18
21
 
19
22
  /**
20
23
  * Every finding `abide check` reports for the app at `appRoot`.
@@ -36,27 +39,40 @@ export async function checkApp(
36
39
  const { root, sources } = await sourcesUnder(appRoot)
37
40
  return withChecker(root, kept, async (session) => {
38
41
  const refused: Diagnosis[] = []
39
- const parsed = parseSources(sources, refused)
42
+ const everyOwn = parseSources(sources, refused)
43
+ await writeTypes(root, everyOwn, true, refused)
44
+ // A file whose projection was refused is found already, and every later step projects it.
45
+ const refusedPaths = new Set<string>()
46
+ for (const found of refused) refusedPaths.add(found.path)
47
+ const own: typeof everyOwn = []
48
+ for (const source of everyOwn)
49
+ if (!refusedPaths.has(source.path)) own.push(source)
50
+ const resolve = resolverIn()
51
+ const parsed = await withDependencies(root, own, resolve, refused)
40
52
  const { read, warnings } = await readSources(session, parsed, refused)
53
+ // THE APP'S OWN FILES ARE REPORTED ON: a dependency's is compiled with them (34.27), and
54
+ // what is wrong inside a package is the package's to report.
55
+ const reported: ReadSource[] = []
56
+ for (const source of read)
57
+ if (placeOf(relative(root, source.path)) !== 'dependency')
58
+ reported.push(source)
41
59
  const { diagnoses: found, refused: loweringRefused } =
42
- await session.diagnose(read, root)
60
+ await session.diagnose(reported, root)
43
61
  const { ambient, unkeyed } = await session.lints(root, parsed)
44
62
  // A FILE WHOSE LOWERING REFUSED is `found` already, at its line — emitted too, it throws the
45
63
  // same refusal at the root and takes the rest of the app's findings with it.
46
64
  const emittable: ReadSource[] = []
47
65
  for (const source of read)
48
66
  if (!loweringRefused.has(source.path)) emittable.push(source)
67
+ // 23.16-23.18 — what the build's walk would refuse of the tree, at each file it names.
68
+ appFiles(root, (path, message) => {
69
+ refused.push(refusal(`${root}/${path}`, message))
70
+ })
49
71
  if (refused.length === 0 && ambient.length === 0)
50
72
  try {
51
73
  warnings.push(
52
- ...(
53
- await emitApp(
54
- root,
55
- session,
56
- emittable,
57
- relative(root, buildDirectory(root)),
58
- )
59
- ).warnings,
74
+ ...(await emitApp(root, session, emittable, resolve))
75
+ .warnings,
60
76
  )
61
77
  } catch (thrown) {
62
78
  refused.push(refusal(root, thrown))
@@ -65,10 +81,12 @@ export async function checkApp(
65
81
  })
66
82
  }
67
83
 
84
+ const FILES_CONFIG = /^\s*onConfig\s*\(/m
85
+
68
86
  /**
69
87
  * The findings for one file as an editor holds it, saved or not.
70
88
  *
71
- * 38.12 — ONE FILE AS AN EDITOR HOLDS IT, saved or not: the same findings `checkApp` has for it, for
89
+ * 38.11 — ONE FILE AS AN EDITOR HOLDS IT, saved or not: the same findings `checkApp` has for it, for
72
90
  * `abide lsp` to publish. The rest of the app is read from disk, as it stands — save a file `held`
73
91
  * has, which is one the editor holds too.
74
92
  *
@@ -78,12 +96,11 @@ export async function checkApp(
78
96
  */
79
97
  export async function checkSource(
80
98
  checker: AppChecker,
81
- root: string,
82
99
  path: string,
83
100
  text: string,
84
101
  held: (path: string) => string | undefined = () => undefined,
85
102
  ): Promise<Diagnosis[]> {
86
- const session = checker.session
103
+ const { session, root } = checker
87
104
  const refused: Diagnosis[] = []
88
105
  const [page] = parseSources([{ path, text }], refused)
89
106
  if (page === undefined) return refused
@@ -91,7 +108,7 @@ export async function checkSource(
91
108
  for (const imported of abideImportsOf(
92
109
  path,
93
110
  page.file,
94
- resolverIn(root),
111
+ resolverIn(),
95
112
  ).values())
96
113
  try {
97
114
  imports.push({
@@ -102,6 +119,17 @@ export async function checkSource(
102
119
  } catch {
103
120
  // A component that cannot be read takes anything here.
104
121
  }
122
+ // 27.13 — AND EVERY FILE FILING `onConfig`, quietly, so a page that only reads `config()` is
123
+ // typed by defaults it never imported — as `checkApp`'s whole-app program types it. The scan
124
+ // only names candidates (a statement opening on the call); the checker decides which file.
125
+ const lowered = new Set(imports.map((one) => one.path))
126
+ const { sources } = await checker.sources()
127
+ for (const other of sources) {
128
+ if (other.path === path || lowered.has(other.path)) continue
129
+ const text = held(other.path) ?? other.text
130
+ if (FILES_CONFIG.test(text))
131
+ imports.push({ path: other.path, text, quiet: true })
132
+ }
105
133
  // A component that does not parse, or that the checker refuses, is its own file's finding and
106
134
  // takes anything here: a quiet source's refusal is reported by nobody.
107
135
  const { read, warnings } = await readSources(