@abide/abide 0.109.0 → 0.110.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.109.0",
3
+ "version": "0.110.1",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -17,7 +17,7 @@ before writing code, rather than guessing from Svelte, Vue or React. It installs
17
17
  TypeScript service do not read `.abide` files, so a clean `tsc` says nothing about them. Run
18
18
  `bun run check` after every change, and treat its warnings as bugs: several of them name a read
19
19
  that will not update when its value changes.
20
- - `bun run test` — runs `bun test`. `src/tests/getGreeting.test.ts` is the pattern: a test imports a
20
+ - `bun run test` — runs `bun test`. `src/getGreeting.test.ts` is the pattern: a test imports a
21
21
  handler or a `.abide` file and calls it.
22
22
  - `bun run dev` — serves the app on a local port and rebuilds when a file changes.
23
23
  - `bun run build` then `bun run start` — the production build, and the app served from it.
@@ -32,9 +32,15 @@ before writing code, rather than guessing from Svelte, Vue or React. It installs
32
32
  scripts, the CLI and MCP alike.
33
33
  - A `.abide` cannot export. A `<script server>` in one holds that file's private handlers
34
34
  (`const save = POST(…)`), callable by that file alone, and its hooks (`onStart`, `middleware`, …).
35
- - A directory holds addresses and four fenced directories alone: `lib/` for components and modules,
36
- `tests/` for tests, `server/` for modules the browser never loads (importing one from browser code
37
- is a compile error), and `public/` for files served as they are (`src/blog/public/cover.png`
38
- answers `/blog/cover.png`). Anything else beside the pages is a build error.
35
+ - A directory holds addresses, tests, `.d.ts` declarations and two fenced directories alone: `lib/` for components and
36
+ modules, and `public/` for files served as they are (`src/blog/public/cover.png` answers
37
+ `/blog/cover.png`). Anything else beside the pages is a build error.
38
+ - A test is a `.test.ts`, kept beside the file it tests — a page, a handler, or a module in `lib/`.
39
+ A browser test (playwright) is a `.test.ts` too, under `e2e/` beside `src/`: `bun test` reads
40
+ `src/` alone (`root` in `bunfig.toml`), so it never runs one.
41
+ - A module the browser never loads is named `<name>.server.ts` and lives in `lib/`
42
+ (`src/invoices/lib/db.server.ts`); importing one from browser code is a compile error. A module it
43
+ imports is not fenced by it.
39
44
  - Import the app's own modules as `#src/…` (see `imports` in `package.json`).
40
- - A test imports `.abide` files directly: `bunfig.toml` preloads `@abide/abide/test`.
45
+ - A test imports `.abide` files directly: `bunfig.toml` preloads `@abide/harness/abide/preload`,
46
+ a DOM to mount into and then `@abide/abide/test`.
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from 'bun:test'
2
- import getGreeting from '../getGreeting.ts'
2
+ import getGreeting from './getGreeting.ts'
3
3
 
4
4
  test('the handler answers with the name it is given', async () => {
5
5
  expect(await getGreeting({ name: 'Ada' })).toBe('Hello, Ada')
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from 'bun:test'
2
2
  import { flushSync } from '@abide/abide'
3
3
  import { measure, mount } from '@abide/harness/abide'
4
- import Index from '../index.abide'
4
+ import Index from './index.abide'
5
5
 
6
6
  test('a click rewrites the count and builds nothing', async () => {
7
7
  using _page = await mount(Index({}))
@@ -1,5 +1,4 @@
1
1
  import { relative as relativeTo } from 'node:path' // bun exposes no path helpers of its own
2
- import type { Compilation } from '#server/abideModules.ts'
3
2
  import { appFiles } from '#server/appTree.ts'
4
3
  import { type Placement, placement, routeOf } from '#server/placement.ts'
5
4
  import { LAUNCHER_PAGE } from '#shared/LAUNCHER_FILES.ts'
@@ -77,7 +76,6 @@ export type AppLayout = {
77
76
  errors: AppRoute[]
78
77
  /** Every public file's source path to its address. 36.11. */
79
78
  published: Map<string, string>
80
- mounted: Compilation['mounted']
81
79
  /** 30.26 — every address a link may name: a page's, and a public file's. */
82
80
  addresses: string[]
83
81
  /** What the tree refuses, each at its file; a build stops naming every one. */
@@ -160,7 +158,6 @@ export async function appLayout(root: string): Promise<AppLayout> {
160
158
  routes,
161
159
  errors,
162
160
  published,
163
- mounted: found.mounted,
164
161
  addresses: [...addresses].sort(),
165
162
  refused: refusals.found,
166
163
  unparsed: unparsed.found,
@@ -201,7 +198,6 @@ export async function withDependencies(
201
198
  ): Promise<{
202
199
  sources: ParsedSource[]
203
200
  handlers: HandlerFile[]
204
- mounted: Compilation['mounted']
205
201
  /** 23.19 — each mounted `.abide`'s position, by its path; one at its own path is not here. */
206
202
  positions: Map<string, string>
207
203
  }> {
@@ -215,7 +211,6 @@ export async function withDependencies(
215
211
  })
216
212
  // The mounted handler files placed so far; the app's own is never mounted (23.20).
217
213
  const handled = new Set<string>()
218
- const mounted: Compilation['mounted'] = {}
219
214
  const byPath = new Map<string, ParsedSource>()
220
215
  for (const source of all) byPath.set(source.path, source)
221
216
  const positions = new Map<string, string>()
@@ -258,7 +253,6 @@ export async function withDependencies(
258
253
  continue
259
254
  }
260
255
  mountedBy.set(mount.directory, position)
261
- mounted[relativeTo(root, mount.directory)] = mount.at
262
256
  for (const placed of mount.handlers) {
263
257
  // Two mounts can overlap, one directory inside the other; a file is placed once.
264
258
  if (handled.has(placed.path)) continue
@@ -283,7 +277,7 @@ export async function withDependencies(
283
277
  if (!byPath.has(target)) await add(target)
284
278
  }
285
279
  }
286
- return { sources: all, handlers, mounted, positions }
280
+ return { sources: all, handlers, positions }
287
281
  }
288
282
 
289
283
  /** Whether a page or an error page has anything to paint: a template, or a head. 23.3, 35.14. */
@@ -294,8 +288,7 @@ export function paints(file: AbideFile): boolean {
294
288
  /**
295
289
  * Refuses what a source's place forbids it.
296
290
  *
297
- * 34.20 — a server block under a `server` or `public` directory: neither holds a component that
298
- * runs. 34.21 — a file imported as text that is not there, found at build rather than when its
291
+ * 34.20 — a server block under a `public` directory, which holds no component that runs. 34.21 — a file imported as text that is not there, found at build rather than when its
299
292
  * module first loads. 35.16 — of the files with an address, the root layout alone writes the
300
293
  * document.
301
294
  */
@@ -306,14 +299,11 @@ function refusePlace(
306
299
  refusals: Refusals,
307
300
  ): void {
308
301
  const { relative, place, kind } = placed
309
- if (
310
- source.file.servers.length > 0 &&
311
- (place === 'server' || place === 'public')
312
- )
302
+ if (source.file.servers.length > 0 && place === 'public')
313
303
  refusals.at(
314
304
  relative,
315
305
  new Error(
316
- `abide: ${relative} has a <script server>, and it is under a ${place} directory, where nothing has an address. Move the file out of it, or the code into a module under a server directory. RULEBOOK 34.20.`,
306
+ `abide: ${relative} has a <script server>, and it is under a public directory, where nothing has an address. Move the file out of it, or the code into a server file under a lib directory. RULEBOOK 34.20.`,
317
307
  ),
318
308
  )
319
309
  const from = source.path.slice(0, source.path.lastIndexOf('/'))
@@ -340,7 +330,7 @@ function refusePlace(
340
330
  }
341
331
 
342
332
  /**
343
- * Refuses, naming the module, every module under a `server` or a `lib` directory that calls a hook.
333
+ * Refuses, naming the module, every module under a `lib` directory that calls a hook.
344
334
  *
345
335
  * 37.13 — a hook called in a module that is neither a `.abide` nor a handler file runs as the module
346
336
  * is imported, before any file's route is set, and not at all where nothing imports it.
@@ -172,7 +172,6 @@ export function compilationOf(app: CompiledApp): Compilation {
172
172
  files: app.files,
173
173
  handlers: app.handlers,
174
174
  tables: app.tables,
175
- mounted: app.mounted,
176
175
  }
177
176
  }
178
177
 
@@ -211,7 +210,6 @@ export async function compiled(
211
210
  files: {},
212
211
  tables: {},
213
212
  handlers: {},
214
- mounted: layout.mounted,
215
213
  routes: [],
216
214
  mounts: [],
217
215
  sources: layout.own,
@@ -95,7 +95,7 @@ export async function handlersUnder(
95
95
  throw new Error(
96
96
  each.name === 'default'
97
97
  ? `abide: ${relative}'s default export is not declared as a handler. A handler file's default is declared by abide's \`GET(…)\` (or POST, PUT, PATCH, DELETE); anything else belongs under a lib directory. RULEBOOK 16.73.`
98
- : `abide: ${relative} exports \`${each.name}\`, and a handler file exports its handler as its default and nothing else. Move \`${each.name}\` into a module under a lib or a server directory. RULEBOOK 16.73.`,
98
+ : `abide: ${relative} exports \`${each.name}\`, and a handler file exports its handler as its default and nothing else. Move \`${each.name}\` into a module under a lib directory. RULEBOOK 16.73.`,
99
99
  )
100
100
  if (handler?.method === undefined)
101
101
  throw new Error(
@@ -34,10 +34,10 @@ const SOURCE_ALIAS = { '#src/*': ['./src/*', './src/*.ts'] }
34
34
  /**
35
35
  * 38.109, 38.119 — the starter's `bunfig.toml`: the harness's abide preload, which is a DOM a test
36
36
  * can mount into and measure, then abide's own — the app's `.abide` files imported as modules, with
37
- * nothing booted first.
37
+ * nothing booted first. Its test root is `src`, so a browser test under `e2e` is playwright's alone.
38
38
  */
39
39
  export const TEST_PRELOAD =
40
- '# 38.109, 38.119 — a DOM to mount into and measure, then the app\'s `.abide` files as modules.\n[test]\npreload = ["@abide/harness/abide/preload"]\n'
40
+ '# 38.109, 38.119 — a DOM to mount into and measure, then the app\'s `.abide` files as modules.\n[test]\nroot = "src"\npreload = ["@abide/harness/abide/preload"]\n'
41
41
 
42
42
  // WRITTEN, NOT COPIED, beside the manifests: a packer drops a `bunfig.toml` or a `.gitignore` from
43
43
  // the tarball (`bun pm pack` both, `npm pack` the second), so neither survives as a starter file.
@@ -63,11 +63,11 @@ function filesFor(name: string): Record<string, string> {
63
63
  },
64
64
  // ONE ALIAS FOR THE SOURCE, and two targets for it, because an extensionless
65
65
  // specifier is how an import is written and only bun probes for the extension:
66
- // TypeScript takes an `imports` target as the exact file, so `#src/media/server/db`
66
+ // TypeScript takes an `imports` target as the exact file, so `#src/media/lib/db.server`
67
67
  // alone left every name it imports `any` and the checker could not type one of
68
68
  // them (31.3). The first target is what bun resolves, and the one a `.ts` or
69
- // `.abide` specifier needs. Which side a module is on is its directory's to say
70
- // (36.10), not its alias's.
69
+ // `.abide` specifier needs. Which side a module is on is its name's to say (36.10),
70
+ // not its alias's.
71
71
  imports: SOURCE_ALIAS,
72
72
  dependencies: { '@abide/abide': ABIDE_DEPENDENCY },
73
73
  // `Bun`, `bun:sqlite` and the rest of the runtime a server module reaches for are
@@ -75,11 +75,6 @@ export type Compilation = {
75
75
  handlers: Record<string, HandlerFace>
76
76
  /** The build's own modules, by name below `__abide`. */
77
77
  tables: Record<string, string>
78
- /**
79
- * 23.19 — every directory a file mounts, by its path below the root, to the position its files
80
- * take: what the browser's fence reads a mounted file's place by (16.29).
81
- */
82
- mounted: Record<string, string>
83
78
  }
84
79
 
85
80
  const RELATIVE_OR_SUBPATH = /^(?:\.\.?\/|#)/
@@ -1,11 +1,11 @@
1
1
  // 23.14 — WHERE AN APP'S ADDRESSES ARE, read off the tree in one walk. A file directly in a directory
2
2
  // below the root is an address — a page, a layout, an error page or a handler file — and nothing
3
- // else is there (23.22); a fence is never one (23.15): `server` fences a graph, `public` holds files
4
- // answered as authored at the path of the directories above it, and `lib` and `tests` hold the rest.
5
- // So listing a directory lists its routes.
3
+ // else is there but its tests (23.22); a fence is never one (23.15): `public` holds files answered as
4
+ // authored at the path of the directories above it, and `lib` holds the rest, a server file among it
5
+ // (23.23). So listing a directory lists its routes.
6
6
  //
7
- // ONE WALK for the sources, the handler files, the server and lib modules, and the public files
8
- // `abide build` copies and `abide dev` answers, and the refusals (23.16, 23.17, 23.18, 23.22) — each
7
+ // ONE WALK for the sources, the handler files, the lib modules, and the public files `abide build`
8
+ // copies and `abide dev` answers, and the refusals (23.16, 23.17, 23.18, 23.22, 23.23) — each
9
9
  // file what `placeOf` says of its path, so the walk and every reader of one path agree, and ONE SKIP
10
10
  // RULE, `skipped`, for every walk of the tree.
11
11
  //
@@ -16,12 +16,18 @@ import { type Dirent, existsSync, readdirSync } from 'node:fs' // bun has no dir
16
16
  import type { Refusals } from '#compiler/Refusals.ts'
17
17
  import { ABIDE_ADDRESS } from '#shared/ADDRESSES.ts'
18
18
  import {
19
- HOLDS_SERVER,
20
19
  PUBLIC_DIRECTORY,
21
20
  RESERVED_DIRECTORIES,
22
- SERVER_DIRECTORY,
23
21
  } from '#shared/RESERVED_DIRECTORIES.ts'
24
- import { DIRECTORY_FILES, placeOf, routeOf, skipped } from './placement.ts'
22
+ import {
23
+ DIRECTORY_FILES,
24
+ isDeclarationFile,
25
+ isServerFile,
26
+ isTestFile,
27
+ placeOf,
28
+ routeOf,
29
+ skipped,
30
+ } from './placement.ts'
25
31
 
26
32
  /** What one walk of the app finds, every path relative to the root. */
27
33
  type AppFiles = {
@@ -29,15 +35,15 @@ type AppFiles = {
29
35
  abide: string[]
30
36
  /** Every handler file, in path order. */
31
37
  handlers: string[]
32
- /** Every module under a `server` or a `lib` directory, in path order — read for 37.13. */
38
+ /** Every module under a `lib` directory, in path order — read for 37.13. */
33
39
  modules: string[]
34
40
  /** Every file under a `public` directory, to the address it is answered at below the mount. */
35
41
  published: Map<string, string>
36
42
  }
37
43
 
38
44
  /**
39
- * The app's `.abide` sources, handler files, server and lib modules and public files, refusing a
40
- * tree 23.16, 23.17, 23.18 or 23.22 forbids into `refusals`, each at its path below the root.
45
+ * The app's `.abide` sources, handler files, lib modules and public files, refusing a tree 23.16,
46
+ * 23.17, 23.18, 23.22 or 23.23 forbids into `refusals`, each at its path below the root.
41
47
  *
42
48
  * A missing root is an app with nothing in it, not a fault.
43
49
  */
@@ -82,14 +88,7 @@ function walk(
82
88
  if (entry.isDirectory()) {
83
89
  let within = reserved
84
90
  if (RESERVED_DIRECTORIES.has(entry.name)) {
85
- // 23.16 — `server` nests in the fences that hold code.
86
- if (
87
- reserved !== undefined &&
88
- !(
89
- entry.name === SERVER_DIRECTORY &&
90
- HOLDS_SERVER.has(reserved.name)
91
- )
92
- )
91
+ if (reserved !== undefined)
93
92
  refusals.at(
94
93
  path,
95
94
  new Error(
@@ -107,17 +106,27 @@ function walk(
107
106
  )
108
107
  continue
109
108
  }
109
+ const place = placeOf(path)
110
+ if (isServerFile(path) && place !== 'lib') {
111
+ refusals.at(
112
+ path,
113
+ new Error(
114
+ `abide: ${path} is a server file under no lib directory. Move it into a lib directory beside it. RULEBOOK 23.23.`,
115
+ ),
116
+ )
117
+ continue
118
+ }
110
119
  const abide = path.endsWith('.abide')
111
120
  if (abide) files.abide.push(path)
112
- const place = placeOf(path)
113
121
  if (place === 'address') {
122
+ if (isTestFile(path) || isDeclarationFile(path)) continue
114
123
  if (!abide && !isModule(path)) {
115
124
  if (packaged && relative === '' && !path.endsWith('.ts'))
116
125
  continue
117
126
  refusals.at(
118
127
  path,
119
128
  new Error(
120
- `abide: ${path} is directly in an address directory, which holds pages, layouts, error pages and handler files alone. Move it into a lib directory beside it. RULEBOOK 23.22.`,
129
+ `abide: ${path} is directly in an address directory, which holds pages, layouts, error pages, handler files, test files and declaration files alone. Move it into a lib directory beside it. RULEBOOK 23.22.`,
121
130
  ),
122
131
  )
123
132
  continue
@@ -139,21 +148,13 @@ function walk(
139
148
  )
140
149
  else if (place === 'public')
141
150
  files.published.set(path, publicAddress(path, fence.path))
142
- else if ((place === 'server' || place === 'lib') && isModule(path))
143
- files.modules.push(path)
151
+ else if (place === 'lib' && isModule(path)) files.modules.push(path)
144
152
  }
145
153
  }
146
154
 
147
- /**
148
- * Whether `path` names a module the app runs — neither a declaration nor a test: directly in an
149
- * address directory, a handler file rather than a stray.
150
- */
155
+ /** Whether `path` names a module the app runs — neither a declaration nor a test. */
151
156
  function isModule(path: string): boolean {
152
- return (
153
- path.endsWith('.ts') &&
154
- !path.endsWith('.d.ts') &&
155
- !path.endsWith('.test.ts')
156
- )
157
+ return path.endsWith('.ts') && !isDeclarationFile(path) && !isTestFile(path)
157
158
  }
158
159
 
159
160
  /** The refusal of `source` where `route` lies in the framework's own namespace, or `undefined`. 23.17. */
@@ -199,7 +200,8 @@ export function holdsApp(root: string): boolean {
199
200
  return !appSourcesUnder(root, '').next().done
200
201
  }
201
202
 
202
- // Every `.abide` under `directory`, and every handler file — a module whose place is an address.
203
+ // Every `.abide` under `directory`, and every handler file — a module whose place is an address, and
204
+ // no server file (23.23).
203
205
  function* appSourcesUnder(
204
206
  directory: string,
205
207
  relative: string,
@@ -217,7 +219,9 @@ function* appSourcesUnder(
217
219
  yield* appSourcesUnder(`${directory}/${entry.name}`, `${path}/`)
218
220
  else if (
219
221
  path.endsWith('.abide') ||
220
- (isModule(path) && placeOf(path) === 'address')
222
+ (isModule(path) &&
223
+ !isServerFile(path) &&
224
+ placeOf(path) === 'address')
221
225
  )
222
226
  yield path
223
227
  }
@@ -52,10 +52,10 @@ export async function clientBundle(
52
52
  // tables' is not on disk to open.
53
53
  root,
54
54
  // 16.28, 16.29 — the browser's view of the server: a `<script server>` is its stand-ins and a
55
- // `server/` module refuses — FENCED FIRST, before the plugin that would serve the module. 38.43
55
+ // server file refuses — FENCED FIRST, before the plugin that would serve the module. 38.43
56
56
  // — then the app's own, as `bunfig.toml` declares them.
57
57
  plugins: [
58
- fenceServer(root, compilation.mounted),
58
+ fenceServer(root),
59
59
  abideModules(root, compilation),
60
60
  ...(await appPlugins(root)),
61
61
  ],
@@ -1,63 +1,57 @@
1
- // 16.29 — WHAT THE BROWSER'S BUNDLE SEES OF THE SERVER: nothing under a `server` directory, at any
2
- // depth, so a secret or a database client cannot ride into the browser by being imported from the
3
- // wrong file. A handler crosses as the wrapper the compiler wrote in its place — a handler file as
4
- // that file, a private handler in its server block's place (16.28); this is the fence around
1
+ // 16.29 — WHAT THE BROWSER'S BUNDLE SEES OF THE SERVER: no server file, `<name>.server.<extension>`,
2
+ // wherever it lies, so a secret or a database client cannot ride into the browser by being imported
3
+ // from the wrong file. A handler crosses as the wrapper the compiler wrote in its place — a handler
4
+ // file as that file, a private handler in its server block's place (16.28); this is the fence around
5
5
  // everything else.
6
6
  //
7
7
  // A PLUGIN ON THE BUNDLE, NOT A REWRITE OF THE PAGES. A rewrite reaches the specifiers the compiler
8
- // emitted; the bundle reaches every edge — a helper the page imports, importing a server module,
8
+ // emitted; the bundle reaches every edge — a helper the page imports, importing a server file,
9
9
  // included.
10
10
  //
11
11
  // REGISTERED AHEAD OF `abideModules`, AND IN ITS NAMESPACES: a load is answered by the first plugin
12
12
  // that answers it, and a component or a file's text is loaded there rather than as a `file`.
13
13
  //
14
- // BY POSITION, NOT BY PATH (23.19): a directory a file mounts places its files where the app says,
15
- // so a `server` directory inside it is fenced as the app's own would be, wherever the package lies.
14
+ // BY NAME, NOT BY PLACE: the file's own name says it, so a dependency's or a mounted package's is
15
+ // fenced as the app's own is, and a module it imports is not (36.10).
16
16
 
17
- import { SERVER_DIRECTORY } from '#shared/RESERVED_DIRECTORIES.ts'
18
- import type { Compilation } from './abideModules.ts'
19
17
  import {
20
18
  COMPONENT_NAMESPACE,
21
19
  realFileOf,
22
20
  STYLE_NAMESPACE,
23
21
  TEXT_NAMESPACE,
24
22
  } from './faceOf.ts'
25
- import { placeOf, positionOf } from './placement.ts'
23
+ import { isServerFile } from './placement.ts'
26
24
 
27
25
  // Every namespace a browser bundle loads a file of the app's in: its modules, its components, a
28
26
  // file's text and a component's stylesheet.
29
27
  const FENCED = ['file', COMPONENT_NAMESPACE, TEXT_NAMESPACE, STYLE_NAMESPACE]
30
28
 
31
29
  /**
32
- * The bundler plugin refusing everything under a `server` directory to the browser's bundle.
30
+ * The bundler plugin refusing every server file to the browser's bundle.
33
31
  *
34
- * `root` is its real path (`appRoot`), which is how the bundler names a file; `mounted` is the
35
- * compilation's. Listed before `abideModules`.
32
+ * `root` is its real path (`appRoot`), which is how the bundler names a file. Listed before
33
+ * `abideModules`.
36
34
  */
37
- export function fenceServer(
38
- root: string,
39
- mounted: Compilation['mounted'],
40
- ): Bun.BunPlugin {
41
- // Anything with a `server` directory in its path, and `placeOf` decides by its position — a
42
- // load it passes on falls through to the next plugin's, or the bundler's own.
43
- const filter = new RegExp(`/${SERVER_DIRECTORY}/`)
35
+ export function fenceServer(root: string): Bun.BunPlugin {
36
+ // Anything whose own name could be one, a face's suffix included, and `isServerFile` decides by
37
+ // the file a face stands for — a load it passes on falls through to the next plugin's, or the
38
+ // bundler's own.
39
+ const filter = /\.server\.[^/]*$/
44
40
  return {
45
41
  name: 'abide-fence',
46
42
  setup(build) {
47
43
  for (const namespace of FENCED)
48
44
  build.onLoad({ filter, namespace }, (args) => {
49
- const position = positionOf(
50
- root,
51
- mounted,
52
- realFileOf(args.path),
53
- )
54
- if (
55
- position === undefined ||
56
- placeOf(position) !== 'server'
57
- )
58
- return undefined
45
+ const path = realFileOf(args.path)
46
+ if (!isServerFile(path)) return undefined
47
+ const dependency = path.lastIndexOf('/node_modules/')
48
+ const shown = path.startsWith(`${root}/`)
49
+ ? path.slice(root.length + 1)
50
+ : dependency === -1
51
+ ? path
52
+ : path.slice(dependency + 1)
59
53
  throw new Error(
60
- `abide: ${position} is under a server directory, and the browser's bundle imports it. RULEBOOK 16.29.`,
54
+ `abide: ${shown} is a server file, and the browser's bundle imports it. RULEBOOK 16.29.`,
61
55
  )
62
56
  })
63
57
  },
@@ -253,6 +253,14 @@ export class McpSurface {
253
253
  handlers: Map<string, Mounted>,
254
254
  answer: (request: Request) => Promise<Response>,
255
255
  ) {
256
+ // 20.20 — a tool's arguments are an object, so args that can never be one are no tool.
257
+ for (const mounted of handlers.values()) {
258
+ const tool = surfaceOf(mounted, 'mcp')
259
+ if (tool !== undefined && toolInput(tool.schema) === undefined)
260
+ throw new Error(
261
+ `abide: ${tool.name}'s args can never be an object, which is all an MCP tool is called with — give it object args, or \`clients: { mcp: false }\`. RULEBOOK 20.20.`,
262
+ )
263
+ }
256
264
  this.#handlers = handlers
257
265
  this.#answer = answer
258
266
  }
@@ -280,7 +288,7 @@ export class McpSurface {
280
288
  listing.push({
281
289
  name: tool.name,
282
290
  description: tool.description ?? '',
283
- inputSchema: tool.schema ?? { type: 'object' },
291
+ inputSchema: toolInput(tool.schema),
284
292
  // 20.14 — what the method promises: a `GET` is safe, and a `GET`, a `PUT` and a
285
293
  // `DELETE` idempotent. Any other write may destroy, and nothing says it does not.
286
294
  annotations: {
@@ -411,6 +419,32 @@ function logCall(
411
419
  )
412
420
  }
413
421
 
422
+ /**
423
+ * `schema` as a tool's `inputSchema`, its top-level `type` `object` whatever it declared there, or
424
+ * `undefined` where no value it admits is an object. 20.19, 20.20.
425
+ */
426
+ function toolInput(schema: unknown): object | undefined {
427
+ if (schema === undefined || schema === true) return { type: 'object' }
428
+ if (!admitsObject(schema)) return undefined
429
+ return { ...(schema as object), type: 'object' }
430
+ }
431
+
432
+ /** Whether a JSON Schema admits some object — its `type`, and each of `anyOf`, `oneOf` and `allOf`. */
433
+ function admitsObject(schema: unknown): boolean {
434
+ if (schema === true) return true
435
+ if (schema === null || typeof schema !== 'object') return false
436
+ const { type, anyOf, oneOf, allOf } = schema as Record<string, unknown>
437
+ if (
438
+ type !== undefined &&
439
+ type !== 'object' &&
440
+ !(Array.isArray(type) && type.includes('object'))
441
+ )
442
+ return false
443
+ if (Array.isArray(anyOf) && !anyOf.some(admitsObject)) return false
444
+ if (Array.isArray(oneOf) && !oneOf.some(admitsObject)) return false
445
+ return !Array.isArray(allOf) || allOf.every(admitsObject)
446
+ }
447
+
414
448
  /**
415
449
  * Whether a tool declares an `outputSchema`: MCP's structured content is an object, so only a
416
450
  * result schema that is one. 20.16.