@abide/abide 0.101.0 → 0.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/abide.ts +226 -164
  3. package/docs/REGISTRY.md +730 -0
  4. package/docs/RULEBOOK.md +2768 -0
  5. package/docs/abide.md +13127 -0
  6. package/package.json +6 -3
  7. package/scaffold/AGENTS.md +38 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +341 -71
  13. package/src/compiler/appMap.ts +96 -0
  14. package/src/compiler/argsSchemaOf.ts +1 -1
  15. package/src/compiler/checkApp.ts +53 -8
  16. package/src/compiler/checkedShadow.ts +73 -6
  17. package/src/compiler/classifyRead.ts +6 -4
  18. package/src/compiler/classifyTemplate.ts +1 -1
  19. package/src/compiler/compileApp.ts +598 -200
  20. package/src/compiler/compiledEntry.ts +48 -40
  21. package/src/compiler/componentGraph.ts +57 -71
  22. package/src/compiler/declaredExports.ts +26 -9
  23. package/src/compiler/emitClient.ts +142 -72
  24. package/src/compiler/emitComponent.ts +41 -12
  25. package/src/compiler/emitSource.ts +102 -13
  26. package/src/compiler/handlersUnder.ts +164 -76
  27. package/src/compiler/hooksCalled.ts +35 -0
  28. package/src/compiler/index.ts +7 -0
  29. package/src/compiler/mountsOf.ts +150 -0
  30. package/src/compiler/nearest.ts +14 -0
  31. package/src/compiler/parseAbide.ts +157 -20
  32. package/src/compiler/projectShadow.ts +250 -19
  33. package/src/compiler/reactiveTypes.ts +20 -7
  34. package/src/compiler/readSources.ts +15 -2
  35. package/src/compiler/readsIn.ts +62 -8
  36. package/src/compiler/renderMarkup.ts +3 -3
  37. package/src/compiler/scaffold.ts +37 -15
  38. package/src/compiler/serveLanguage.ts +101 -34
  39. package/src/compiler/sharedModuleWrites.ts +2 -2
  40. package/src/compiler/sourceMapOf.ts +193 -0
  41. package/src/compiler/splitImports.ts +205 -83
  42. package/src/compiler/tokenize.ts +14 -0
  43. package/src/compiler/writeDefaultProject.ts +29 -2
  44. package/src/server/CLIENT_BUILD.ts +11 -13
  45. package/src/server/DEFAULT_PORT.ts +1 -1
  46. package/src/server/MODULE_QUERIES.ts +27 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +457 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +241 -107
  52. package/src/server/answerRpc.ts +107 -33
  53. package/src/server/answerSocket.ts +65 -20
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +238 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +38 -37
  65. package/src/server/componentModules.d.ts +2 -1
  66. package/src/server/compressAnswer.ts +4 -4
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +77 -79
  70. package/src/server/devWorker.ts +118 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +57 -32
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +137 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +12 -7
  77. package/src/server/installSourceMaps.ts +93 -0
  78. package/src/server/launcherOptions.ts +33 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +6 -6
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/preload.ts +13 -0
  85. package/src/server/principal.ts +10 -5
  86. package/src/server/realOrAsGiven.ts +10 -0
  87. package/src/server/refuseAddressCollision.ts +21 -14
  88. package/src/server/remote.ts +49 -52
  89. package/src/server/render.ts +65 -94
  90. package/src/server/renderComponent.ts +5 -0
  91. package/src/server/reportHealth.ts +2 -2
  92. package/src/server/requestScope.ts +12 -1
  93. package/src/server/runBuild.ts +24 -0
  94. package/src/server/runCompiled.ts +5 -5
  95. package/src/server/runDesktop.ts +48 -28
  96. package/src/server/serveBuild.ts +43 -52
  97. package/src/server/serveDev.ts +79 -37
  98. package/src/server/shell.ts +7 -22
  99. package/src/server/sourceMappings.ts +139 -0
  100. package/src/server/surfaceOf.ts +20 -3
  101. package/src/server/testBuild.ts +1 -19
  102. package/src/server/withAbideSources.ts +117 -0
  103. package/src/shared/ADDRESSES.ts +10 -18
  104. package/src/shared/APP_META.ts +7 -0
  105. package/src/shared/CopyOptions.ts +1 -1
  106. package/src/shared/DEBUG_META.ts +1 -1
  107. package/src/shared/LAUNCHER_FILES.ts +9 -0
  108. package/src/shared/RESERVED_DIRECTORIES.ts +24 -0
  109. package/src/shared/cookies.ts +2 -2
  110. package/src/shared/csp.ts +1 -1
  111. package/src/shared/escapeText.ts +1 -1
  112. package/src/shared/filledPath.ts +85 -0
  113. package/src/shared/framing.ts +79 -6
  114. package/src/shared/headKeyOf.ts +2 -0
  115. package/src/shared/index.ts +2 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/{ui → shared}/menuBridge.ts +2 -2
  119. package/src/shared/menuItem.ts +4 -4
  120. package/src/shared/onMenu.ts +58 -0
  121. package/src/shared/reactive/Keyed.ts +2 -2
  122. package/src/shared/reactive/NotLanded.ts +1 -1
  123. package/src/shared/reactive/Reactive.ts +43 -43
  124. package/src/shared/reactive/argsContract.ts +4 -4
  125. package/src/shared/reactive/canonicalKey.ts +4 -4
  126. package/src/shared/reactive/chunksOfStream.ts +6 -0
  127. package/src/shared/reactive/index.ts +1 -1
  128. package/src/shared/reactive/recordWrites.ts +103 -5
  129. package/src/shared/reactive/structural.ts +1 -1
  130. package/src/shared/reactive/tracking.ts +1 -1
  131. package/src/shared/reactive/watch.ts +3 -3
  132. package/src/shared/refuse.ts +1 -1
  133. package/src/{ui → shared}/reportMenu.ts +8 -10
  134. package/src/shared/routePattern.ts +122 -48
  135. package/src/shared/rpc.ts +37 -8
  136. package/src/shared/rpcRequest.ts +27 -3
  137. package/src/shared/url.ts +4 -44
  138. package/src/shared/writePath.ts +1 -1
  139. package/src/ui/LAYER_NAMES.ts +1 -1
  140. package/src/ui/List.ts +3 -3
  141. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  142. package/src/ui/Mismatch.ts +1 -1
  143. package/src/ui/RawRun.ts +1 -1
  144. package/src/ui/Router.ts +37 -36
  145. package/src/ui/Scope.ts +1 -1
  146. package/src/ui/TryRegion.ts +2 -2
  147. package/src/ui/applyHead.ts +33 -0
  148. package/src/ui/index.ts +1 -1
  149. package/src/ui/moveNode.ts +1 -1
  150. package/src/ui/reconcile.ts +2 -2
  151. package/src/ui/routing.ts +2 -2
  152. package/src/ui/setAttribute.ts +1 -1
  153. package/src/ui/setText.ts +1 -1
  154. package/src/ui/socketWire.ts +9 -7
  155. package/src/ui/wire.ts +1 -1
  156. package/scaffold/src/server/app.ts +0 -6
  157. package/scaffold/src/shared/.gitkeep +0 -0
  158. package/scaffold/src/ui/app.html +0 -8
  159. package/src/server/componentImports.ts +0 -56
  160. /package/scaffold/src/{ui/pages/page.abide → index.abide} +0 -0
  161. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  // 38.1 — `abide scaffold <name>`: THE LAYOUT EVERY ABIDE APP HAS, written into `<name>`. There is
2
- // nothing to choose, because what is scaffolded is the three seams and the files the commands read.
2
+ // nothing to choose, because what is scaffolded is a root layout and a page — the files the commands
3
+ // read — and the test preload.
3
4
  // A file that is already there is left as it is and reported skipped, so a scaffold run over a
4
5
  // half-made project finishes it rather than overwriting it.
5
6
 
@@ -7,8 +8,8 @@ import { withoutTrailingSlash } from '#server/withoutTrailingSlash.ts'
7
8
  import abidePackage from '../../package.json' with { type: 'json' }
8
9
  import { COMPILER_OPTIONS } from './writeDefaultProject.ts'
9
10
 
10
- // THE STARTER'S OWN FILES, as files: a real document, a real page and a real module, copied as they
11
- // stand. What depends on the name — the manifests — is written here.
11
+ // THE STARTER'S OWN FILES, as files: a real document, a real page and the agent notes, copied as
12
+ // they stand. What depends on the name, or what a packer drops (`filesFor`), is written here.
12
13
  const STARTER = Bun.fileURLToPath(new URL('../../scaffold/', import.meta.url))
13
14
 
14
15
  // THE ABIDE THAT WROTE THE PROJECT is the one it depends on. Installed from a registry, that is its
@@ -21,28 +22,44 @@ const ABIDE_DEPENDENCY = PACKAGE_ROOT.includes('/node_modules/')
21
22
  ? `^${abidePackage.version}`
22
23
  : `file:${PACKAGE_ROOT}`
23
24
 
25
+ // The `#src` alias, written as the package's `imports` and again as the checker's `paths`.
26
+ const SOURCE_ALIAS = { '#src/*': ['./src/*', './src/*.ts'] }
27
+
28
+ /**
29
+ * 38.109 — the starter's `bunfig.toml`: a test imports the app's `.abide` files as modules, with
30
+ * nothing booted first.
31
+ */
32
+ export const TEST_PRELOAD =
33
+ '# 38.109 — a test imports the app\'s `.abide` files as modules, with nothing booted first.\n[test]\npreload = ["@abide/abide/test"]\n'
34
+
35
+ // WRITTEN, NOT COPIED, beside the manifests: a packer drops a `bunfig.toml` or a `.gitignore` from
36
+ // the tarball (`bun pm pack` both, `npm pack` the second), so neither survives as a starter file.
24
37
  function filesFor(name: string): Record<string, string> {
25
38
  return {
39
+ 'bunfig.toml': TEST_PRELOAD,
40
+ '.gitignore': 'node_modules\n.abide\n',
26
41
  'package.json': `${JSON.stringify(
27
42
  {
28
43
  name,
29
44
  private: true,
30
45
  type: 'module',
31
46
  scripts: {
47
+ // 38.103 — the checker over `.abide`, which `tsc` does not read.
48
+ check: 'abide check',
32
49
  dev: 'abide dev',
33
50
  build: 'abide build',
34
51
  start: 'abide start',
52
+ // 38.108 — every address, and the file answering it.
53
+ routes: 'abide routes',
35
54
  },
36
- // TWO TARGETS A SEAM, because an extensionless specifier is how the seams are
37
- // written and only bun probes for the extension: TypeScript takes an `imports`
38
- // target as the exact file, so `#server/database` alone left every name it
39
- // imports `any` and the checker could not type one of them (31.3). The first
40
- // target is what bun resolves, and the one a `.ts` or `.abide` specifier needs.
41
- imports: {
42
- '#ui/*': ['./src/ui/*', './src/ui/*.ts'],
43
- '#server/*': ['./src/server/*', './src/server/*.ts'],
44
- '#shared/*': ['./src/shared/*', './src/shared/*.ts'],
45
- },
55
+ // ONE ALIAS FOR THE SOURCE, and two targets for it, because an extensionless
56
+ // specifier is how an import is written and only bun probes for the extension:
57
+ // TypeScript takes an `imports` target as the exact file, so `#src/media/server/db`
58
+ // alone left every name it imports `any` and the checker could not type one of
59
+ // them (31.3). The first target is what bun resolves, and the one a `.ts` or
60
+ // `.abide` specifier needs. Which side a module is on is its directory's to say
61
+ // (36.10), not its alias's.
62
+ imports: SOURCE_ALIAS,
46
63
  dependencies: { '@abide/abide': ABIDE_DEPENDENCY },
47
64
  // `Bun`, `bun:sqlite` and the rest of the runtime a server module reaches for are
48
65
  // typed by these, and `abide check` reports every one untyped without them.
@@ -53,8 +70,14 @@ function filesFor(name: string): Record<string, string> {
53
70
  )}\n`,
54
71
  'tsconfig.json': `${JSON.stringify(
55
72
  // TypeScript 7 includes no `@types` package it is not named, so the runtime's is named.
73
+ // 34.21 — the alias as a path too: the checker resolves a text import's declaration
74
+ // beside the file a path names, and takes an `imports` target as the exact file.
56
75
  {
57
- compilerOptions: { ...COMPILER_OPTIONS, types: ['bun'] },
76
+ compilerOptions: {
77
+ ...COMPILER_OPTIONS,
78
+ types: ['bun'],
79
+ paths: SOURCE_ALIAS,
80
+ },
58
81
  include: ['src'],
59
82
  },
60
83
  null,
@@ -81,7 +104,6 @@ export async function scaffold(
81
104
  const written: string[] = []
82
105
  const skipped: string[] = []
83
106
  const files: [string, string | Blob][] = Object.entries(filesFor(name))
84
- // The seam both sides import from is there from the first commit, `.gitkeep` and all.
85
107
  for (const path of new Bun.Glob('**/*').scanSync({
86
108
  cwd: STARTER,
87
109
  dot: true,
@@ -1,10 +1,12 @@
1
- import { type FSWatcher, watch } from 'node:fs' // bun exposes no file watcher of its own
2
- import { basename, dirname } from 'node:path' // bun exposes no path helpers of its own
1
+ import { existsSync, type FSWatcher, watch } from 'node:fs' // bun exposes no file watcher or sync existence check of its own
2
+ import { dirname } from 'node:path' // bun exposes no path helpers of its own
3
+ import { realOrAsGiven } from '#server/realOrAsGiven.ts'
3
4
  import { checkSource } from './checkApp.ts'
4
5
  import { type AppChecker, openChecker } from './compileApp.ts'
6
+ import { nearest } from './nearest.ts'
5
7
  import type { Diagnosis, Inspection, Place } from './TypeSession.ts'
6
8
 
7
- // 38.12 — `abide lsp`: THE `.abide` LANGUAGE SERVER, OVER STDIO. It is `abide check` (38.3) asked
9
+ // 38.11 — `abide lsp`: THE `.abide` LANGUAGE SERVER, OVER STDIO. It is `abide check` (38.3) asked
8
10
  // about one open document at a time, so the squiggle an editor draws and the line CI fails on are
9
11
  // one finding rather than two implementations that agree most of the time.
10
12
  //
@@ -15,10 +17,14 @@ import type { Diagnosis, Inspection, Place } from './TypeSession.ts'
15
17
  // same text.
16
18
  //
17
19
  // THE APP IS THE DOCUMENT'S. An editor starts this at the root of whatever it has open, which in a
18
- // monorepo is not an app at all, so each document is checked against the directory above the
19
- // OUTERMOST `ui/` it sits in (`appRootOf`) — with one checker per app, kept for the session.
20
+ // monorepo is not an app at all, so each document is checked against the root of the NEAREST
21
+ // package above it (`appRootOf`) — with one checker per app, kept for the session.
20
22
  // `--root` pins every document to one.
21
23
  //
24
+ // THE EDITOR'S SPELLING STOPS HERE. An app opened through a link (`/tmp` is one on macOS) is
25
+ // checked at its real path, the one bun resolves every import to (`openChecker`), so a path is
26
+ // made real on its way in (`placed`) and spelled under the link again on its way out (`spelled`).
27
+ //
22
28
  // ONE CHECK AT A TIME. The checker holds one snapshot, and two checks interleaving would each
23
29
  // register the other's text for a moment; a burst of keystrokes collapses onto the last one.
24
30
 
@@ -52,8 +58,33 @@ export async function serveLanguage(
52
58
  const timers = new Map<string, ReturnType<typeof setTimeout>>()
53
59
  const checkers = new Map<string, AppChecker>()
54
60
  const watchers = new Map<string, FSWatcher>()
61
+ // Each app's real root, to the root the editor reached it by.
62
+ const links = new Map<string, string>()
55
63
  let queue: Promise<void> = Promise.resolve()
56
64
 
65
+ // The document at `uri`, in its app's real spelling; `undefined` where no app holds it.
66
+ const placed = (uri: string): { app: string; path: string } | undefined => {
67
+ const path = Bun.fileURLToPath(uri)
68
+ const given = root ?? appRootOf(path)
69
+ if (given === undefined) return undefined
70
+ const app = realOrAsGiven(given)
71
+ if (app !== given) links.set(app, given)
72
+ return {
73
+ app,
74
+ path: path.startsWith(`${given}/`)
75
+ ? `${app}${path.slice(given.length)}`
76
+ : path,
77
+ }
78
+ }
79
+
80
+ // `path`, real, under the root the editor reached its app by.
81
+ const spelled = (path: string): string => {
82
+ for (const [app, given] of links)
83
+ if (path.startsWith(`${app}/`))
84
+ return `${given}${path.slice(app.length)}`
85
+ return path
86
+ }
87
+
57
88
  const send = (message: object): void => {
58
89
  const body = ENCODER.encode(
59
90
  JSON.stringify({ jsonrpc: '2.0', ...message }),
@@ -118,8 +149,7 @@ export async function serveLanguage(
118
149
  )
119
150
  // What an open page says may be decided by the file that moved.
120
151
  for (const uri of documents.keys())
121
- if ((root ?? appRootOf(Bun.fileURLToPath(uri))) === app)
122
- check(uri)
152
+ if (placed(uri)?.app === app) check(uri)
123
153
  }),
124
154
  )
125
155
  return checker
@@ -129,18 +159,18 @@ export async function serveLanguage(
129
159
  const document = documents.get(uri)
130
160
  if (document === undefined) return
131
161
  const version = document.version
132
- const path = Bun.fileURLToPath(uri)
133
- const app = root ?? appRootOf(path)
134
162
  // A `.abide` belonging to no app has nothing to be checked against.
135
- if (app === undefined) return
163
+ const at = placed(uri)
164
+ if (at === undefined) return
165
+ const { app, path } = at
136
166
  let found: Diagnosis[]
137
167
  try {
138
168
  found = await checkSource(
139
169
  await checkerOf(app),
140
- app,
141
170
  path,
142
171
  document.text,
143
- (held) => documents.get(Bun.pathToFileURL(held).href)?.text,
172
+ (held) =>
173
+ documents.get(Bun.pathToFileURL(spelled(held)).href)?.text,
144
174
  )
145
175
  } catch (thrown) {
146
176
  // The checker's own failure, not the page's: the next check opens another.
@@ -179,7 +209,20 @@ export async function serveLanguage(
179
209
 
180
210
  // A question about a document is about the text the editor shows, so a check still settling
181
211
  // runs now rather than after the answer; and it is answered in the queue, after that check.
182
- const inspect = (params: unknown): Promise<Inspection | undefined> => {
212
+ const inspect = (params: unknown): Promise<Inspection | undefined> =>
213
+ ask(params, (session, path, text, offset) =>
214
+ session.inspect(path, text, offset),
215
+ )
216
+
217
+ const ask = <Answer>(
218
+ params: unknown,
219
+ question: (
220
+ session: AppChecker['session'],
221
+ path: string,
222
+ text: string,
223
+ offset: number,
224
+ ) => Promise<Answer | undefined>,
225
+ ): Promise<Answer | undefined> => {
183
226
  const { textDocument, position } = params as {
184
227
  textDocument: { uri: string }
185
228
  position: { line: number; character: number }
@@ -192,12 +235,11 @@ export async function serveLanguage(
192
235
  }
193
236
  const answered = queue.then(async () => {
194
237
  const document = documents.get(uri)
195
- if (document === undefined) return undefined
196
- const path = Bun.fileURLToPath(uri)
197
- const app = root ?? appRootOf(path)
198
- if (app === undefined) return undefined
199
- return (await checkerOf(app)).session.inspect(
200
- path,
238
+ const at = placed(uri)
239
+ if (document === undefined || at === undefined) return undefined
240
+ return question(
241
+ (await checkerOf(at.app)).session,
242
+ at.path,
201
243
  document.text,
202
244
  offsetOf(document.text, position),
203
245
  )
@@ -220,6 +262,7 @@ export async function serveLanguage(
220
262
  textDocumentSync: 1,
221
263
  hoverProvider: true,
222
264
  definitionProvider: true,
265
+ completionProvider: { triggerCharacters: ['.'] },
223
266
  },
224
267
  serverInfo: { name: 'abide' },
225
268
  },
@@ -279,11 +322,39 @@ export async function serveLanguage(
279
322
  })
280
323
  return true
281
324
  }
325
+ // 38.98 — the protocol's `CompletionItemKind` is the checker's own numbering.
326
+ case 'textDocument/completion': {
327
+ const trigger = (
328
+ params?.context as { triggerCharacter?: string } | undefined
329
+ )?.triggerCharacter
330
+ const found = await ask(params, (session, path, text, offset) =>
331
+ session.complete(path, text, offset, trigger),
332
+ )
333
+ const items: object[] = []
334
+ for (const entry of found?.entries ?? [])
335
+ items.push({
336
+ label: entry.name,
337
+ kind: entry.kind,
338
+ sortText: entry.sortText,
339
+ filterText: entry.filterText,
340
+ insertText: entry.insertText,
341
+ detail: entry.detail,
342
+ labelDetails: entry.labelDetails,
343
+ })
344
+ send({
345
+ id: message.id,
346
+ result:
347
+ found === undefined
348
+ ? null
349
+ : { isIncomplete: found.isIncomplete, items },
350
+ })
351
+ return true
352
+ }
282
353
  case 'textDocument/definition': {
283
354
  const found = await inspect(params)
284
355
  const locations: object[] = []
285
356
  for (const place of found?.definitions ?? [])
286
- locations.push(locationOf(place))
357
+ locations.push(locationOf(spelled(place.path), place))
287
358
  send({ id: message.id, result: locations })
288
359
  return true
289
360
  }
@@ -348,20 +419,16 @@ async function closeAll(
348
419
  }
349
420
 
350
421
  /**
351
- * The app a file at `path` belongs to: the directory above its outermost `ui/`.
422
+ * The app a file at `path` belongs to: the root 38.25 finds from the nearest package above it.
352
423
  *
353
- * OUTERMOST, so a component folder that happens to be called `ui` inside an app is not taken for
354
- * the app. `undefined` where the file is under no `ui/` at all.
424
+ * That package's `src` where it has one, and the package itself where it does not — the same root
425
+ * `abide dev` run in that package would find. `undefined` where no package lies above the file.
355
426
  */
356
427
  function appRootOf(path: string): string | undefined {
357
- let found: string | undefined
358
- let at = dirname(path)
359
- for (;;) {
360
- if (basename(at) === 'ui') found = dirname(at)
361
- const up = dirname(at)
362
- if (up === at) return found
363
- at = up
364
- }
428
+ const manifest = nearest(dirname(path), 'package.json')
429
+ if (manifest === undefined) return undefined
430
+ const at = dirname(manifest)
431
+ return existsSync(`${at}/src`) ? `${at}/src` : at
365
432
  }
366
433
 
367
434
  /**
@@ -390,10 +457,10 @@ function offsetOf(
390
457
  return at + position.character
391
458
  }
392
459
 
393
- /** A `Place`, lines and columns from 1, as the protocol's `Location`, from 0. */
394
- function locationOf(place: Place): object {
460
+ /** A `Place` at `path`, lines and columns from 1, as the protocol's `Location`, from 0. */
461
+ function locationOf(path: string, place: Place): object {
395
462
  return {
396
- uri: Bun.pathToFileURL(place.path).href,
463
+ uri: Bun.pathToFileURL(path).href,
397
464
  range: {
398
465
  start: {
399
466
  line: place.start.line - 1,
@@ -11,7 +11,7 @@ import { classifyRead, FUNCTIONS } from './classifyRead.ts'
11
11
  import type { Finding } from './Finding.ts'
12
12
  import type { Span } from './projectShadow.ts'
13
13
 
14
- // 34.19 — A PLAIN `let` IN A `<script module>` THAT A `<script>` WRITES AS IT SETS UP. The block is
14
+ // 34.18 — A PLAIN `let` IN A `<script module>` THAT A `<script>` WRITES AS IT SETS UP. The block is
15
15
  // module scope (34.2), which a server shares across every request, and setup runs inside a render —
16
16
  // so the value one request leaves is the one the next request starts from, and its markup drifts
17
17
  // from what the browser builds. A `reactive` is per request wherever it is declared (11.32), which
@@ -74,7 +74,7 @@ export async function sharedModuleWrites(
74
74
  if (insideFunction(node)) continue
75
75
  warnings.push({
76
76
  at,
77
- message: `abide: \`${name}\` is a plain \`let\` in <script module>, which a server shares across every request, and this <script> writes it while the page renders — so one request's value is where the next one starts. Declare it with \`reactive\`, which is per request wherever it stands, or move it into <script>. RULEBOOK 34.19.`,
77
+ message: `abide: \`${name}\` is a plain \`let\` in <script module>, which a server shares across every request, and this <script> writes it while the page renders — so one request's value is where the next one starts. Declare it with \`reactive\`, which is per request wherever it stands, or move it into <script>. RULEBOOK 34.18.`,
78
78
  })
79
79
  }
80
80
  }
@@ -0,0 +1,193 @@
1
+ // 38.97 — WHERE A COMPILED MODULE'S LINES CAME FROM IN ITS `.abide`. The emitters build a module
2
+ // as text around what `Lowering` hands them, so a chunk is placed by FINDING it in what they wrote
3
+ // rather than by threading offsets through every template string that assembles one:
4
+ //
5
+ // - a script block is many lines and lands whole, its imports each left as a `;` on as many lines
6
+ // (`splitImports`), so it is anchored by its longest line found once in the module and then read
7
+ // line for line;
8
+ // - a template expression is one line and may land more than once — a client module writes a slot
9
+ // in its markup and again in the effect that updates it — and every landing is the same source.
10
+ //
11
+ // What no chunk covers is the framework's own scaffolding, and maps to nothing.
12
+
13
+ import { encodeMappings, type Segment } from '#server/sourceMappings.ts'
14
+ import type { LoweredChunk } from './Lowering.ts'
15
+ import type { Origin } from './projectShadow.ts'
16
+ import { mapBack } from './ShadowMap.ts'
17
+
18
+ /** The module's `mappings`, into the `.abide` whose text is `source`. */
19
+ export function sourceMapOf(
20
+ output: string,
21
+ chunks: readonly LoweredChunk[],
22
+ projected: readonly Origin[],
23
+ source: string,
24
+ ): string {
25
+ const sourceStarts = lineStarts(source)
26
+ const outputLines = output.split('\n')
27
+ const outputStarts = lineStarts(output)
28
+ const lines: Segment[][] = []
29
+ for (let index = 0; index < outputLines.length; index += 1) lines.push([])
30
+ // Where a projection offset is in the `.abide`, as a line and a column.
31
+ const place = (line: number, column: number, offset: number): void => {
32
+ const at = mapBack(projected, offset)
33
+ const sourceLine = lineAt(sourceStarts, at)
34
+ ;(lines[line] as Segment[]).push([
35
+ column,
36
+ 0,
37
+ sourceLine,
38
+ at - (sourceStarts[sourceLine] as number),
39
+ ])
40
+ }
41
+ const claimed = new Set<number>()
42
+
43
+ const multiline: LoweredChunk[] = []
44
+ const single: LoweredChunk[] = []
45
+ for (const chunk of chunks)
46
+ (chunk.text.includes('\n') ? multiline : single).push(chunk)
47
+ multiline.sort((one, two) => two.text.length - one.text.length)
48
+
49
+ for (const chunk of multiline) {
50
+ const chunkLines = chunk.text.split('\n')
51
+ const first = anchor(chunkLines, outputLines, outputStarts, output)
52
+ if (first === undefined) continue
53
+ const chunkStarts = lineStarts(chunk.text)
54
+ for (let index = 0; index < chunkLines.length; index += 1) {
55
+ const line = first + index
56
+ const written = outputLines[line]
57
+ const lowered = chunkLines[index] as string
58
+ if (written === undefined || claimed.has(line)) continue
59
+ if (written.trim() !== lowered.trim() || lowered.trim() === '')
60
+ continue
61
+ claimed.add(line)
62
+ const start = chunkStarts[index] as number
63
+ const end = start + lowered.length
64
+ // Lines the emitter re-indented keep their text, and move by the margin it added.
65
+ const shift = written.length - lowered.length
66
+ for (const origin of chunk.origins) {
67
+ const runEnd = origin.at + Math.max(origin.length, 1)
68
+ if (origin.at >= end || runEnd <= start) continue
69
+ const from = Math.max(origin.at, start)
70
+ place(
71
+ line,
72
+ from - start + shift,
73
+ origin.source + (from - origin.at),
74
+ )
75
+ }
76
+ }
77
+ }
78
+
79
+ // LONGEST FIRST, each landing claimed: `count() + 1` is found again inside the handler
80
+ // `() => { count.set(count() + 1) }` was lowered from, and is that handler's there.
81
+ single.sort((one, two) => two.text.trim().length - one.text.trim().length)
82
+ const taken: [number, number][] = []
83
+ const seen = new Set<string>()
84
+ for (const chunk of single) {
85
+ const text = chunk.text.trim()
86
+ if (text.length < 2) continue
87
+ // An expression an emitter asked for twice is one chunk.
88
+ const key = `${chunk.origins[0]?.source}:${text}`
89
+ if (seen.has(key)) continue
90
+ seen.add(key)
91
+ const lead = chunk.text.indexOf(text)
92
+ for (
93
+ let found = output.indexOf(text);
94
+ found !== -1;
95
+ found = output.indexOf(text, found + text.length)
96
+ ) {
97
+ const end = found + text.length
98
+ if (!standsAlone(output, found, end)) continue
99
+ const line = lineAt(outputStarts, found)
100
+ if (claimed.has(line)) continue
101
+ if (taken.some(([from, to]) => found < to && end > from)) continue
102
+ taken.push([found, end])
103
+ const column = found - (outputStarts[line] as number)
104
+ for (const origin of chunk.origins) {
105
+ const at = origin.at - lead
106
+ if (at < 0 || at > text.length) continue
107
+ place(line, column + at, origin.source)
108
+ }
109
+ }
110
+ }
111
+
112
+ // Two runs placed at one column — a pin and the copy after it — keep the later, which is the
113
+ // author's own text.
114
+ for (const segments of lines) {
115
+ segments.sort((one, two) => one[0] - two[0])
116
+ for (let at = segments.length - 1; at > 0; at -= 1)
117
+ if (
118
+ (segments[at - 1] as Segment)[0] ===
119
+ (segments[at] as Segment)[0]
120
+ )
121
+ segments.splice(at - 1, 1)
122
+ }
123
+ return encodeMappings(lines)
124
+ }
125
+
126
+ /**
127
+ * The module line `chunkLines` starts on: its longest line found exactly once, standing where the
128
+ * rest of the chunk agrees — or `undefined` where no line places it.
129
+ */
130
+ function anchor(
131
+ chunkLines: readonly string[],
132
+ outputLines: readonly string[],
133
+ outputStarts: readonly number[],
134
+ output: string,
135
+ ): number | undefined {
136
+ const order: number[] = []
137
+ for (let index = 0; index < chunkLines.length; index += 1) order.push(index)
138
+ order.sort(
139
+ (one, two) =>
140
+ (chunkLines[two] as string).trim().length -
141
+ (chunkLines[one] as string).trim().length,
142
+ )
143
+ for (const index of order) {
144
+ const text = (chunkLines[index] as string).trim()
145
+ if (text.length < 8) return undefined
146
+ const found = output.indexOf(text)
147
+ if (found === -1 || output.indexOf(text, found + 1) !== -1) continue
148
+ const first = lineAt(outputStarts, found) - index
149
+ if (first < 0) continue
150
+ let agreeing = 0
151
+ let written = 0
152
+ for (let at = 0; at < chunkLines.length; at += 1) {
153
+ const lowered = (chunkLines[at] as string).trim()
154
+ if (lowered === '') continue
155
+ written += 1
156
+ if (outputLines[first + at]?.trim() === lowered) agreeing += 1
157
+ }
158
+ if (agreeing * 2 >= written) return first
159
+ }
160
+ return undefined
161
+ }
162
+
163
+ const NAME = /[\w$.]/
164
+
165
+ // Not the middle of a longer name, or a member of one: `count()` inside `__count()` is not it.
166
+ function standsAlone(output: string, start: number, end: number): boolean {
167
+ return (
168
+ !NAME.test(output[start - 1] ?? ' ') &&
169
+ !/[\w$]/.test(output[end] ?? ' ')
170
+ )
171
+ }
172
+
173
+ function lineStarts(text: string): number[] {
174
+ const starts = [0]
175
+ for (
176
+ let at = text.indexOf('\n');
177
+ at !== -1;
178
+ at = text.indexOf('\n', at + 1)
179
+ )
180
+ starts.push(at + 1)
181
+ return starts
182
+ }
183
+
184
+ function lineAt(starts: readonly number[], offset: number): number {
185
+ let low = 0
186
+ let high = starts.length - 1
187
+ while (low < high) {
188
+ const middle = (low + high + 1) >>> 1
189
+ if ((starts[middle] as number) <= offset) low = middle
190
+ else high = middle - 1
191
+ }
192
+ return low
193
+ }