@abide/abide 0.102.0 → 0.104.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 (73) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/bin/abide.ts +31 -28
  3. package/docs/REGISTRY.md +40 -36
  4. package/docs/RULEBOOK.md +113 -92
  5. package/docs/abide.md +788 -554
  6. package/package.json +2 -1
  7. package/scaffold/AGENTS.md +13 -6
  8. package/src/compiler/TypeSession.ts +108 -35
  9. package/src/compiler/appMap.ts +91 -0
  10. package/src/compiler/argsSchemaOf.ts +1 -1
  11. package/src/compiler/checkApp.ts +35 -18
  12. package/src/compiler/checkedShadow.ts +40 -7
  13. package/src/compiler/compileApp.ts +271 -76
  14. package/src/compiler/compiledEntry.ts +10 -6
  15. package/src/compiler/componentGraph.ts +6 -7
  16. package/src/compiler/declaredExports.ts +3 -3
  17. package/src/compiler/emitClient.ts +3 -3
  18. package/src/compiler/emitComponent.ts +9 -9
  19. package/src/compiler/emitSource.ts +5 -9
  20. package/src/compiler/handlersUnder.ts +114 -73
  21. package/src/compiler/hooksCalled.ts +35 -0
  22. package/src/compiler/mountsOf.ts +30 -18
  23. package/src/compiler/parseAbide.ts +12 -12
  24. package/src/compiler/projectShadow.ts +71 -46
  25. package/src/compiler/readsIn.ts +2 -2
  26. package/src/compiler/scaffold.ts +24 -21
  27. package/src/compiler/serveLanguage.ts +0 -24
  28. package/src/compiler/sharedModuleWrites.ts +2 -2
  29. package/src/compiler/splitImports.ts +4 -4
  30. package/src/compiler/writeDefaultProject.ts +28 -1
  31. package/src/server/MODULE_QUERIES.ts +12 -3
  32. package/src/server/abideModules.ts +156 -53
  33. package/src/server/answerRequest.ts +9 -4
  34. package/src/server/answerRpc.ts +18 -16
  35. package/src/server/appTree.ts +92 -25
  36. package/src/server/commands.ts +5 -4
  37. package/src/server/componentModules.d.ts +1 -2
  38. package/src/server/createApp.ts +23 -11
  39. package/src/server/devWorker.ts +5 -1
  40. package/src/server/fenceServer.ts +36 -13
  41. package/src/server/generatedSurfaces.ts +12 -53
  42. package/src/server/hooks.ts +17 -4
  43. package/src/server/index.ts +1 -0
  44. package/src/server/installSourceMaps.ts +6 -1
  45. package/src/server/launcherOptions.ts +12 -13
  46. package/src/server/launcherPage.ts +2 -2
  47. package/src/server/nativeMenu.ts +3 -3
  48. package/src/server/preload.ts +13 -0
  49. package/src/server/runDesktop.ts +11 -16
  50. package/src/server/serveDev.ts +8 -2
  51. package/src/server/surfaceOf.ts +2 -0
  52. package/src/server/versionOf.ts +1 -2
  53. package/src/server/withAbideSources.ts +1 -1
  54. package/src/shared/ADDRESSES.ts +3 -0
  55. package/src/shared/LAUNCHER_FILES.ts +9 -0
  56. package/src/shared/RESERVED_DIRECTORIES.ts +19 -4
  57. package/src/shared/index.ts +2 -1
  58. package/src/{ui → shared}/menuBridge.ts +1 -1
  59. package/src/shared/menuItem.ts +2 -2
  60. package/src/shared/onMenu.ts +58 -0
  61. package/src/shared/reactive/Cursor.ts +66 -123
  62. package/src/shared/reactive/Reactive.ts +74 -28
  63. package/src/shared/reactive/callLoad.ts +4 -18
  64. package/src/shared/reactive/chunksOfStream.ts +10 -34
  65. package/src/{ui → shared}/reportMenu.ts +7 -9
  66. package/src/shared/routePattern.ts +74 -23
  67. package/src/shared/rpc.ts +1 -1
  68. package/src/ui/Router.ts +0 -6
  69. package/src/compiler/syncTypes.ts +0 -32
  70. package/src/compiler/writeTypes.ts +0 -82
  71. package/src/server/LAUNCHER_FILES.ts +0 -9
  72. /package/scaffold/src/{page.abide → index.abide} +0 -0
  73. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.102.0",
3
+ "version": "0.104.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -40,6 +40,7 @@
40
40
  "./routing": "./src/ui/routing.ts",
41
41
  "./wire": "./src/ui/wire.ts",
42
42
  "./server": "./src/server/index.ts",
43
+ "./test": "./src/server/preload.ts",
43
44
  "./compiler": "./src/compiler/index.ts",
44
45
  "./package.json": "./package.json"
45
46
  },
@@ -22,10 +22,17 @@ before writing code, rather than guessing from Svelte, Vue or React. It installs
22
22
 
23
23
  ## Layout
24
24
 
25
- - Every directory under `src/` is an address: `src/invoices/page.abide` answers `/invoices`.
26
- `layout.abide` wraps the pages below it, and the outermost one writes `<html>`.
27
- - A module under a `server` directory runs on the server only, and importing one from browser
28
- code is a compile error. A file under a `public` directory is served as it is:
29
- `src/blog/public/cover.png` answers `/blog/cover.png`.
30
- - Handlers and hooks go in a `<script server>` block of a `.abide` file.
25
+ - A file's path below `src/` is its address. `src/invoices/index.abide` answers `/invoices`,
26
+ `src/invoices/[id].abide` answers `/invoices/123`, and `layout.abide` wraps the pages below it;
27
+ the outermost one writes `<html>`. Run `bun run routes` to see every address and the file behind it.
28
+ - A `.ts` beside the pages is a handler: `src/invoices/getInvoice.ts` holds
29
+ `export default GET(…)` and answers `/invoices/getInvoice`. It is the app's public API, for pages,
30
+ scripts, the CLI and MCP alike.
31
+ - A `.abide` cannot export. A `<script server>` in one holds that file's private handlers
32
+ (`const save = POST(…)`), callable by that file alone, and its hooks (`onStart`, `middleware`, …).
33
+ - A directory holds addresses and four fenced directories alone: `lib/` for components and modules,
34
+ `tests/` for tests, `server/` for modules the browser never loads (importing one from browser code
35
+ is a compile error), and `public/` for files served as they are (`src/blog/public/cover.png`
36
+ answers `/blog/cover.png`). Anything else beside the pages is a build error.
31
37
  - Import the app's own modules as `#src/…` (see `imports` in `package.json`).
38
+ - A test imports `.abide` files directly: `bunfig.toml` preloads `@abide/abide/test`.
@@ -21,6 +21,7 @@ import {
21
21
  COMPONENT_MODULE,
22
22
  COMPONENT_STAND_IN,
23
23
  checkedShadow,
24
+ moduleConfigShadow,
24
25
  } from './checkedShadow.ts'
25
26
  import type { Finding } from './Finding.ts'
26
27
  import { CHECKED_VALUES } from './Lowering.ts'
@@ -65,12 +66,17 @@ export class TypeSession {
65
66
  */
66
67
  private shadows = new Map<string, { path: string; text: string }>()
67
68
  /**
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.
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.
71
72
  */
72
- private routes: { within: string; declaration: string } | undefined =
73
- undefined
73
+ private walked:
74
+ | {
75
+ within: string
76
+ routes: string
77
+ configModules: Promise<[string, string][]>
78
+ }
79
+ | undefined = undefined
74
80
  /** The checker's own key for a path: resolved, and case-folded where its host folds case. */
75
81
  private keyOf: (path: string) => string
76
82
  /**
@@ -101,7 +107,7 @@ export class TypeSession {
101
107
  /**
102
108
  * The project this session opens: the app's tsconfig, with every shadow in `outside` a root.
103
109
  *
104
- * 34.29 — A DEPENDENCY'S `.abide` IS TYPED AS THE APP'S OWN, and its tsconfig's `include` never
110
+ * 34.25 — A DEPENDENCY'S `.abide` IS TYPED AS THE APP'S OWN, and its tsconfig's `include` never
105
111
  * reaches `node_modules` or a linked package's directory. The session opens a config of its own —
106
112
  * served from here, never written — that extends the app's, names those shadows as files, which
107
113
  * TypeScript adds to what the base config includes (a linked package's shadow, asked for through
@@ -157,7 +163,7 @@ export class TypeSession {
157
163
  /**
158
164
  * The shadow registered at `name`, or at the same file through its directory's real path.
159
165
  *
160
- * 34.29 — A LINKED PACKAGE IS ASKED FOR THROUGH ITS LINK: the checker resolves `kit/x.abide` to
166
+ * 34.25 — A LINKED PACKAGE IS ASKED FOR THROUGH ITS LINK: the checker resolves `kit/x.abide` to
161
167
  * `node_modules/kit/x.d.abide.ts` and the re-export there to the shadow beside it, while the
162
168
  * shadow is registered where bun resolved the file — the package's real directory.
163
169
  */
@@ -180,17 +186,17 @@ export class TypeSession {
180
186
  }
181
187
 
182
188
  private standIn(name: string): string | undefined {
183
- // 34.25 — a file imported as text is its text, which the projection names apart.
189
+ // 34.21 — a file imported as text is its text, which the projection names apart.
184
190
  if (name.endsWith(`${SOURCE_SUFFIX}.d.ts`))
185
191
  return isFile(name.slice(0, -`${SOURCE_SUFFIX}.d.ts`.length))
186
192
  ? 'declare const text: string\nexport default text\n'
187
193
  : 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.
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.
192
198
  //
193
- // 34.29 — AND THROUGH A PACKAGE'S `exports`, which the checker follows to the exact file and
199
+ // 34.25 — AND THROUGH A PACKAGE'S `exports`, which the checker follows to the exact file and
194
200
  // so asks for the arbitrary extension's declaration, `./x.d.abide.ts`, instead.
195
201
  const abide = name.endsWith(DECLARATION_SUFFIX)
196
202
  ? name.slice(0, -'.d.ts'.length)
@@ -200,8 +206,7 @@ export class TypeSession {
200
206
  if (abide !== undefined) {
201
207
  if (!isFile(abide)) return undefined
202
208
  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)}'
209
+ return `export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
205
210
  `
206
211
  }
207
212
  // A projection not registered — a file with nothing to read, or one this session has not
@@ -353,7 +358,7 @@ export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
353
358
  * checker is not told what was under it.
354
359
  */
355
360
  edited(paths: string[] | undefined): void {
356
- this.routes = undefined
361
+ this.walked = undefined
357
362
  if (paths === undefined) {
358
363
  this.everything = true
359
364
  return
@@ -442,14 +447,49 @@ export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
442
447
  }
443
448
 
444
449
  /**
445
- * 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.
446
461
  *
447
462
  * THE SNAPSHOT IS THE SESSION'S, AND A QUESTION IS ANY MODULE'S: what a handler declares
448
463
  * (`declaredExports`) is asked of the same snapshot the reads were, taken only where something
449
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.
450
468
  */
451
- project(): Promise<Project | undefined> {
452
- 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])
453
493
  }
454
494
 
455
495
  /**
@@ -482,7 +522,7 @@ export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
482
522
  /**
483
523
  * Where the checker holds a `.abide`'s projection, once `readsFor` has registered it.
484
524
  *
485
- * 19.15 — what a `<script server>` exports is read off it as a `.ts` module's exports are.
525
+ * 16.70 — what a `<script server>` binds is read off it as a `.ts` module's exports are.
486
526
  */
487
527
  projectionOf(abidePath: string): string {
488
528
  return `${this.canonical(abidePath)}${SHADOW_SUFFIX}`
@@ -581,8 +621,8 @@ export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
581
621
  const scripts = projection.text.slice(0, projection.scripts.end)
582
622
  // A TEMPLATE POSITION IS A QUESTION TOO (14.2): a file that declares nothing but
583
623
  // 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.
624
+ // `<script server>` always is (16.70): what it binds is read off this shadow, re-exported
625
+ // under `PRIVATE_EXPORT`.
586
626
  if (
587
627
  !DECLARES.test(scripts) &&
588
628
  projection.slots.size === 0 &&
@@ -606,13 +646,8 @@ export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
606
646
  // AN UNCHANGED SHADOW IS NOT REPORTED, which is what a kept session saves on a save:
607
647
  // the one page that moved is re-read rather than all of them.
608
648
  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
- }
649
+ if (placeOf(this.belowRoot(source.path)) === 'dependency')
650
+ this.openOutside(map.shadowPath, changed)
616
651
  queries.push({
617
652
  map,
618
653
  spans: {
@@ -819,14 +854,30 @@ export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
819
854
  created,
820
855
  changed,
821
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 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
+ }
822
875
  // 30.26 — THE APP'S ROUTE TABLE, which `url`'s literal is held to: every page under the
823
876
  // root, declared into abide's own `Routes`. A module rather than a script, since only a
824
877
  // module augments one.
825
- if (this.routes?.within !== within)
826
- this.routes = { within, declaration: routesDeclaration(within) }
827
878
  this.register(
828
879
  `${this.canonical(within)}/${ROUTES_DECLARATION}`,
829
- this.routes.declaration,
880
+ this.walked.routes,
830
881
  created,
831
882
  changed,
832
883
  )
@@ -1106,10 +1157,11 @@ const URL_SOURCE = Bun.fileURLToPath(new URL('../shared/url', import.meta.url))
1106
1157
  * 30.26 — every page under `root`, and every public file's address, as a key of abide's `Routes`, or
1107
1158
  * nothing where it has neither.
1108
1159
  */
1109
- function routesDeclaration(root: string): string {
1160
+ function routesDeclaration(
1161
+ root: string,
1162
+ { abide, published }: ReturnType<typeof appFiles>,
1163
+ ): string {
1110
1164
  const routes = new Set<string>()
1111
- // A refused tree is `checkApp`'s finding (38.33), never the checker's crash.
1112
- const { abide, published } = appFiles(root, () => {})
1113
1165
  for (const path of abide)
1114
1166
  if (fileKind(path) === 'page') routes.add(routeOf(path))
1115
1167
  // 23.19 — and every page a mount places in the app, at its position.
@@ -1272,3 +1324,24 @@ function flatten(diagnostic: {
1272
1324
  message += `\n ${flatten(link as Parameters<typeof flatten>[0]).replaceAll('\n', '\n ')}`
1273
1325
  return message
1274
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 below `within` that names `onConfig`, with its text — the modules a server
1333
+ * runs that 27.13 types `Config` off, since 37.13 refuses a hook anywhere else that is no `.abide`.
1334
+ */
1335
+ async function configModulesUnder(
1336
+ within: string,
1337
+ { handlers }: ReturnType<typeof appFiles>,
1338
+ ): Promise<[string, string][]> {
1339
+ const paths: string[] = []
1340
+ for (const name of handlers) paths.push(`${within}/${name}`)
1341
+ const texts = await Promise.all(paths.map((path) => Bun.file(path).text()))
1342
+ const found: [string, string][] = []
1343
+ for (let at = 0; at < paths.length; at += 1)
1344
+ if ((texts[at] as string).includes('onConfig'))
1345
+ found.push([paths[at] as string, texts[at] as string])
1346
+ return found
1347
+ }
@@ -0,0 +1,91 @@
1
+ // 38.108 — THE APP'S MAP: every address it answers, the file answering each, and where every hook is
2
+ // filed. What `abide routes` prints, off the build's own reading of the tree — the routes and the
3
+ // handler mounts `compileApp` answers with — so the map and what the server answers are one walk.
4
+ //
5
+ // THE HOOKS ARE READ, NOT RUN, as the handlers are: a call of one of abide's hook names in a server
6
+ // block, a `<script>` or a handler file, at the route 37.1 files it at.
7
+
8
+ import { readFileSync } from 'node:fs' // bun exposes no sync read of its own
9
+ import { appFiles } from '#server/appTree.ts'
10
+ import { scopeOf } from '#shared/routePattern.ts'
11
+ import { compileApp } from './compileApp.ts'
12
+ import { hooksCalled } from './hooksCalled.ts'
13
+ import { parseAbide } from './parseAbide.ts'
14
+
15
+ /** One address the app answers, and the file answering it. */
16
+ type MappedRoute = {
17
+ method: string
18
+ address: string
19
+ file: string
20
+ kind: 'page' | 'handler' | 'private'
21
+ }
22
+
23
+ /** One hook call, the route it is filed at, and the file making it. */
24
+ type MappedHook = { hook: string; route: string; file: string }
25
+
26
+ /** Every address the app at `root` answers, and every hook it files. */
27
+ export async function appMap(
28
+ root: string,
29
+ ): Promise<{ routes: MappedRoute[]; hooks: MappedHook[] }> {
30
+ const app = await compileApp(root)
31
+ const routes: MappedRoute[] = []
32
+ for (const route of app.routes)
33
+ routes.push({
34
+ method: 'GET',
35
+ address: route.pattern,
36
+ file: route.source,
37
+ kind: 'page',
38
+ })
39
+ for (const mount of app.mounts)
40
+ routes.push({
41
+ method: mount.method,
42
+ address: mount.address,
43
+ file: mount.source,
44
+ kind: mount.private ? 'private' : 'handler',
45
+ })
46
+ routes.sort((one, two) =>
47
+ one.address === two.address
48
+ ? one.method < two.method
49
+ ? -1
50
+ : 1
51
+ : one.address < two.address
52
+ ? -1
53
+ : 1,
54
+ )
55
+ const hooks: MappedHook[] = []
56
+ const { abide, handlers } = appFiles(root, () => {})
57
+ for (const file of abide) {
58
+ const parsed = parseAbide(readFileSync(`${root}/${file}`, 'utf8'))
59
+ // Block by block: one block's last token would decide how the next one's first reads.
60
+ for (const script of [...parsed.servers, ...parsed.scripts])
61
+ for (const hook of hooksCalled(script))
62
+ hooks.push({ hook, route: scopeOf(file), file })
63
+ }
64
+ for (const file of handlers)
65
+ for (const hook of hooksCalled(readFileSync(`${root}/${file}`, 'utf8')))
66
+ hooks.push({ hook, route: scopeOf(file), file })
67
+ return { routes, hooks }
68
+ }
69
+
70
+ /** The map as `abide routes` prints it: one line an address, then one a hook. */
71
+ export function mapText(map: Awaited<ReturnType<typeof appMap>>): string {
72
+ const lines: string[] = []
73
+ const width = (values: string[]): number =>
74
+ values.reduce((most, value) => Math.max(most, value.length), 0)
75
+ const methods = width(map.routes.map((route) => route.method))
76
+ const addresses = width(map.routes.map((route) => route.address))
77
+ for (const route of map.routes)
78
+ lines.push(
79
+ `${route.method.padEnd(methods)} ${route.address.padEnd(addresses)} ${route.file}${route.kind === 'private' ? ' (private)' : ''}`,
80
+ )
81
+ if (map.hooks.length > 0) {
82
+ lines.push('', 'hooks')
83
+ const hooks = width(map.hooks.map((hook) => hook.hook))
84
+ const routes = width(map.hooks.map((hook) => hook.route))
85
+ for (const hook of map.hooks)
86
+ lines.push(
87
+ ` ${hook.hook.padEnd(hooks)} ${hook.route.padEnd(routes)} ${hook.file}`,
88
+ )
89
+ }
90
+ return lines.join('\n')
91
+ }
@@ -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.19 — A MEMBER THE TYPE DOES NOT DECLARE IS REFUSED, where no index signature declares the
137
+ // 19.18 — 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
@@ -4,12 +4,14 @@ import { placeOf } from '#shared/routePattern.ts'
4
4
  import {
5
5
  type AppChecker,
6
6
  emitApp,
7
+ refuseModuleHooks,
7
8
  resolverIn,
8
9
  sourcesUnder,
9
10
  withChecker,
10
11
  withDependencies,
11
12
  } from './compileApp.ts'
12
13
  import { abideImportsOf } from './componentGraph.ts'
14
+ import { projectShadow } from './projectShadow.ts'
13
15
  import {
14
16
  parseSources,
15
17
  type ReadSource,
@@ -17,7 +19,6 @@ import {
17
19
  refusal,
18
20
  } from './readSources.ts'
19
21
  import type { Diagnosis } from './TypeSession.ts'
20
- import { writeTypes } from './writeTypes.ts'
21
22
 
22
23
  /**
23
24
  * Every finding `abide check` reports for the app at `appRoot`.
@@ -39,18 +40,30 @@ export async function checkApp(
39
40
  const { root, sources } = await sourcesUnder(appRoot)
40
41
  return withChecker(root, kept, async (session) => {
41
42
  const refused: Diagnosis[] = []
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)
43
+ const own = parseSources(sources, refused)
44
+ // 23.16-23.18 and 37.13 — what the build's walk would refuse of the tree, at each file it names.
45
+ // 38.33 — each at its file, and every one rather than the first.
46
+ const atFile = (relative: string, thrown: unknown): void => {
47
+ refused.push(refusal(`${root}/${relative}`, thrown))
48
+ }
49
+ const { handlers, modules } = appFiles(root, atFile)
50
+ refuseModuleHooks(root, modules, atFile)
50
51
  const resolve = resolverIn()
51
- const parsed = await withDependencies(root, own, resolve, refused)
52
+ const { sources: everyParsed, handlers: handlerFiles } =
53
+ await withDependencies(root, own, handlers, resolve, refused)
54
+ // 38.33 — A FILE WHOSE PROJECTION IS REFUSED (34.3, 34.17) is a finding at its file, found
55
+ // here because every later step projects it and would throw the refusal for the whole app.
56
+ // AFTER THE IMPORTS ARE FOLLOWED, so a refused file another one imports is found too.
57
+ const parsed: typeof everyParsed = []
58
+ for (const source of everyParsed)
59
+ try {
60
+ projectShadow(source.file)
61
+ parsed.push(source)
62
+ } catch (thrown) {
63
+ refused.push(refusal(source.path, thrown))
64
+ }
52
65
  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
66
+ // THE APP'S OWN FILES ARE REPORTED ON: a dependency's is compiled with them (34.23), and
54
67
  // what is wrong inside a package is the package's to report.
55
68
  const reported: ReadSource[] = []
56
69
  for (const source of read)
@@ -64,15 +77,19 @@ export async function checkApp(
64
77
  const emittable: ReadSource[] = []
65
78
  for (const source of read)
66
79
  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
- })
71
80
  if (refused.length === 0 && ambient.length === 0)
72
81
  try {
73
82
  warnings.push(
74
- ...(await emitApp(root, session, emittable, resolve))
75
- .warnings,
83
+ ...(
84
+ await emitApp(
85
+ root,
86
+ session,
87
+ emittable,
88
+ resolve,
89
+ handlerFiles,
90
+ atFile,
91
+ )
92
+ ).warnings,
76
93
  )
77
94
  } catch (thrown) {
78
95
  refused.push(refusal(root, thrown))
@@ -127,7 +144,7 @@ export async function checkSource(
127
144
  for (const other of sources) {
128
145
  if (other.path === path || lowered.has(other.path)) continue
129
146
  const text = held(other.path) ?? other.text
130
- if (FILES_CONFIG.test(text))
147
+ if (text.includes('onConfig') && FILES_CONFIG.test(text))
131
148
  imports.push({ path: other.path, text, quiet: true })
132
149
  }
133
150
  // A component that does not parse, or that the checker refuses, is its own file's finding and
@@ -1,3 +1,4 @@
1
+ import { SERVER_SPECIFIER } from '#shared/SPECIFIERS.ts'
1
2
  import type { CheckerAnswer } from './CheckerAnswer.ts'
2
3
  import type { Finding } from './Finding.ts'
3
4
  import {
@@ -11,7 +12,6 @@ import { holdsReactive } from './PositionTable.ts'
11
12
  import type { AbideFile } from './parseAbide.ts'
12
13
  import { NO_PROPS, type Origin, type Span } from './projectShadow.ts'
13
14
  import { CHECKED_SUFFIX } from './ShadowMap.ts'
14
- import { SERVER_SPECIFIER } from './SPECIFIERS.ts'
15
15
  import { abideImports } from './splitImports.ts'
16
16
 
17
17
  // 38.3 — A `.abide` FILE AS THE PROGRAM IT RUNS AS, for the checker to report on. The projection
@@ -48,10 +48,11 @@ export const COMPONENT_STAND_IN = '__component'
48
48
  */
49
49
  function componentModule(props: string, generics?: string): string {
50
50
  // 34.13 — generic over what the file's `<script>` declares, so a caller's props infer it, the
51
- // signature's own parameters shadowing the opaque stand-ins the body was checked against.
51
+ // signature's own parameters shadowing the opaque stand-ins the body was checked against. 34.16 —
52
+ // a component no prop is required of is called with none, as the runtime's bound face takes it.
52
53
  const parameters = generics === undefined ? '' : `<${generics}>`
53
54
  return `
54
- declare const ${COMPONENT_STAND_IN}: ${parameters}(record: ${props}) => import('${SERVER_SPECIFIER}').Component
55
+ declare const ${COMPONENT_STAND_IN}: ${parameters}(...record: {} extends ${props} ? [record?: ${props}] : [record: ${props}]) => import('${SERVER_SPECIFIER}').Component
55
56
  export default ${COMPONENT_STAND_IN}
56
57
  `
57
58
  }
@@ -141,15 +142,22 @@ function propsOf(lowering: Lowering): string {
141
142
  * Nothing where the file files none.
142
143
  */
143
144
  function configDeclarations(lowering: Lowering): string {
145
+ const bound: string[] = []
146
+ for (const read of lowering.answer.reads)
147
+ if (read.shape === 'config')
148
+ bound.push(`${CHECKED_CONFIG}${read.start}`)
149
+ return configInterface(bound)
150
+ }
151
+
152
+ /** `Config` extended by what each of `bound` holds — the binding of one `onConfig` call each. */
153
+ function configInterface(bound: readonly string[]): string {
154
+ if (bound.length === 0) return ''
144
155
  let aliases = ''
145
156
  let names = ''
146
- for (const read of lowering.answer.reads) {
147
- if (read.shape !== 'config') continue
148
- const name = `${CHECKED_CONFIG}${read.start}`
157
+ for (const name of bound) {
149
158
  aliases += `type ${name}Type = typeof ${name}\n`
150
159
  names += names === '' ? `${name}Type` : `, ${name}Type`
151
160
  }
152
- if (names === '') return ''
153
161
  return `declare function ${CHECKED_CONFIG_OF}<Options>(on: (defaults: never, options?: Options) => () => void): <Defaults>(defaults: ((env: import('${SERVER_SPECIFIER}').Env) => Defaults) | null, options?: Options) => [Defaults] extends [object] ? Pick<Defaults, keyof Defaults> : {}
154
162
  ${aliases}declare module '${SERVER_SPECIFIER}' {
155
163
  interface Config extends ${names} {}
@@ -157,6 +165,31 @@ ${aliases}declare module '${SERVER_SPECIFIER}' {
157
165
  `
158
166
  }
159
167
 
168
+ // A call standing as a statement at a module's top level, as a formatted module writes one.
169
+ const TOP_LEVEL_CONFIG = /^onConfig\s*\(/gm
170
+
171
+ /**
172
+ * 27.13 FOR A HANDLER FILE, the one module that is no `.abide` and may call a hook (37.13): its
173
+ * text with each `onConfig(…)` at its top level bound as the lowering binds a server block's, and
174
+ * `Config` extended by each, or `undefined` where it files none.
175
+ *
176
+ * A MODULE OF ITS OWN BESIDE THE FILE, which the checker reads and nothing reports on: the file's own
177
+ * text is the one its diagnostics are placed in, and binding a call shifts every column after it.
178
+ */
179
+ export function moduleConfigShadow(text: string): string | undefined {
180
+ const bound: string[] = []
181
+ let out = ''
182
+ let from = 0
183
+ for (const found of text.matchAll(TOP_LEVEL_CONFIG)) {
184
+ const name = `${CHECKED_CONFIG}${bound.length}`
185
+ bound.push(name)
186
+ out += `${text.slice(from, found.index)}const ${name} = ${CHECKED_CONFIG_OF}(onConfig)(`
187
+ from = found.index + found[0].length
188
+ }
189
+ if (bound.length === 0) return undefined
190
+ return `${out}${text.slice(from)}\n${configInterface(bound)}`
191
+ }
192
+
160
193
  /**
161
194
  * Points each `.abide` import in the script blocks at its checked module, and moves `origins` to
162
195
  * match.