@abide/abide 0.105.1 → 0.107.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 (100) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/abide.ts +4 -1
  3. package/docs/REGISTRY.md +156 -44
  4. package/docs/RULEBOOK.md +392 -88
  5. package/docs/abide.md +7245 -10248
  6. package/package.json +4 -1
  7. package/scaffold/AGENTS.md +2 -0
  8. package/scaffold/src/getGreeting.ts +6 -0
  9. package/scaffold/src/index.abide +3 -1
  10. package/scaffold/src/tests/getGreeting.test.ts +6 -0
  11. package/scaffold/src/tests/index.test.ts +18 -0
  12. package/src/compiler/DOCS_SITE.ts +2 -0
  13. package/src/compiler/Lowering.ts +234 -14
  14. package/src/compiler/PositionTable.ts +14 -7
  15. package/src/compiler/PositionedRefusal.ts +13 -0
  16. package/src/compiler/Refusals.ts +18 -11
  17. package/src/compiler/SHARED_NAMES.ts +2 -0
  18. package/src/compiler/ShadowMap.ts +37 -0
  19. package/src/compiler/TEMPLATE_BLOCKS.ts +12 -0
  20. package/src/compiler/TypeSession.ts +410 -31
  21. package/src/compiler/appLayout.ts +18 -2
  22. package/src/compiler/argsSchemaOf.ts +55 -9
  23. package/src/compiler/checkApp.ts +33 -0
  24. package/src/compiler/checkedShadow.ts +36 -22
  25. package/src/compiler/citedClauses.ts +14 -0
  26. package/src/compiler/classifyRead.ts +6 -0
  27. package/src/compiler/classifyTemplate.ts +7 -4
  28. package/src/compiler/compileApp.ts +2 -2
  29. package/src/compiler/declaredExports.ts +27 -6
  30. package/src/compiler/emitClient.ts +306 -103
  31. package/src/compiler/handlersUnder.ts +4 -0
  32. package/src/compiler/invocationProps.ts +1 -1
  33. package/src/compiler/isIgnored.ts +27 -0
  34. package/src/compiler/parseAbide.ts +176 -76
  35. package/src/compiler/projectShadow.ts +30 -6
  36. package/src/compiler/readSources.ts +1 -1
  37. package/src/compiler/readsIn.ts +100 -1
  38. package/src/compiler/renderMarkup.ts +36 -5
  39. package/src/compiler/scaffold.ts +19 -6
  40. package/src/compiler/serveLanguage.ts +156 -21
  41. package/src/compiler/templateContext.ts +150 -0
  42. package/src/compiler/tokenize.ts +6 -0
  43. package/src/server/BRIDGE_VERSION.ts +1 -1
  44. package/src/server/Inbox.ts +8 -0
  45. package/src/server/MCP_PROTOCOL_VERSION.ts +1 -1
  46. package/src/server/MOUNTABLE.ts +6 -0
  47. package/src/server/abideModules.ts +8 -1
  48. package/src/server/answerRequest.ts +3 -1
  49. package/src/server/answerRpc.ts +26 -12
  50. package/src/server/answerSocket.ts +26 -1
  51. package/src/server/argsFor.ts +4 -3
  52. package/src/server/assistant.ts +21 -14
  53. package/src/server/bridgeRelay.ts +108 -20
  54. package/src/server/claudeApi.ts +19 -11
  55. package/src/server/claudeCode.ts +19 -14
  56. package/src/server/commands.ts +73 -9
  57. package/src/server/createApp.ts +41 -7
  58. package/src/server/devWorker.ts +3 -3
  59. package/src/server/generatedSurfaces.ts +37 -4
  60. package/src/server/muxUpgrade.ts +47 -0
  61. package/src/server/preload.ts +13 -2
  62. package/src/server/render.ts +4 -1
  63. package/src/server/runCompiled.ts +1 -0
  64. package/src/server/seedBuffer.ts +31 -1
  65. package/src/server/serveDev.ts +83 -72
  66. package/src/server/surfaceOf.ts +3 -0
  67. package/src/server/testMount.ts +53 -0
  68. package/src/shared/ADDRESSES.ts +1 -1
  69. package/src/shared/ERROR_META.ts +3 -0
  70. package/src/shared/Schema.ts +1 -1
  71. package/src/shared/WORK.ts +14 -12
  72. package/src/shared/applyPatch.ts +1 -1
  73. package/src/shared/boundValue.ts +81 -0
  74. package/src/shared/index.ts +7 -2
  75. package/src/shared/jsonSchema.ts +25 -2
  76. package/src/shared/log.ts +11 -1
  77. package/src/shared/reactive/Keyed.ts +30 -6
  78. package/src/shared/reactive/PATCHES.ts +1 -1
  79. package/src/shared/reactive/Reactive.ts +118 -60
  80. package/src/shared/reactive/canonicalKey.ts +1 -1
  81. package/src/shared/reactive/index.ts +13 -5
  82. package/src/shared/reactive/recordWrites.ts +1 -1
  83. package/src/shared/reactive/watch.ts +26 -4
  84. package/src/shared/rpc.ts +6 -1
  85. package/src/shared/url.ts +7 -3
  86. package/src/shared/validateJson.ts +1 -1
  87. package/src/ui/ArmRegion.ts +2 -2
  88. package/src/ui/List.ts +9 -9
  89. package/src/ui/RawRun.ts +1 -1
  90. package/src/ui/Router.ts +194 -36
  91. package/src/ui/Scope.ts +29 -0
  92. package/src/ui/adoptWalk.ts +3 -3
  93. package/src/ui/applyHead.ts +25 -3
  94. package/src/ui/bindElement.ts +4 -0
  95. package/src/ui/bindProperty.ts +70 -22
  96. package/src/ui/index.ts +1 -1
  97. package/src/ui/listenForRoutes.ts +14 -3
  98. package/src/ui/reconcile.ts +23 -28
  99. package/src/ui/regions.ts +9 -2
  100. package/src/ui/rows.ts +108 -28
@@ -1,15 +1,24 @@
1
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
- import { type Node, NodeFlags, SyntaxKind } from 'typescript/unstable/ast'
3
+ import {
4
+ getTouchingPropertyName,
5
+ type Node,
6
+ NodeFlags,
7
+ SyntaxKind,
8
+ } from 'typescript/unstable/ast'
4
9
  import {
5
10
  API,
6
11
  type Symbol as CheckedSymbol,
7
12
  type Checker,
13
+ type CompletionEntry,
8
14
  type CompletionInfo,
15
+ CompletionItemKind,
9
16
  NodeBuilderFlags,
10
17
  type Project,
11
18
  SignatureKind,
19
+ type Snapshot,
12
20
  SymbolFlags,
21
+ type Type,
13
22
  } from 'typescript/unstable/async'
14
23
  import { placeOf } from '#server/placement.ts'
15
24
  import { aliasedSymbol } from './aliasedSymbol.ts'
@@ -24,15 +33,17 @@ import {
24
33
  moduleConfigShadow,
25
34
  } from './checkedShadow.ts'
26
35
  import type { Finding } from './Finding.ts'
36
+ import { isIgnored } from './isIgnored.ts'
27
37
  import { CHECKED_VALUES } from './Lowering.ts'
28
38
  import { diagnosisAt, lineOf } from './lineOf.ts'
29
39
  import type { AbideFile } from './parseAbide.ts'
30
- import { projectShadow } from './projectShadow.ts'
40
+ import { projectShadow, type Span } from './projectShadow.ts'
31
41
  import { abideIn } from './reactiveTypes.ts'
32
42
  import type { ParsedSource, ReadSource } from './readSources.ts'
33
43
  import { readsIn, type ShadowSpans } from './readsIn.ts'
34
44
  import { CHECKED_SUFFIX, SHADOW_SUFFIX, ShadowMap } from './ShadowMap.ts'
35
45
  import { SOURCE_SUFFIX } from './splitImports.ts'
46
+ import { type TemplateContext, templateContext } from './templateContext.ts'
36
47
  import { templateWarnings } from './templateWarnings.ts'
37
48
 
38
49
  // Where 31.3 gets its answer. A `.abide` file is projected into a SHADOW `.ts` held in a virtual
@@ -250,6 +261,52 @@ export class TypeSession {
250
261
  * rather than taking a second one.
251
262
  */
252
263
  private latest: Project | undefined = undefined
264
+ private latestSnapshot: Snapshot | undefined = undefined
265
+
266
+ /**
267
+ * Snapshots a later one replaced, released once no call is in flight (`hold`).
268
+ *
269
+ * THE CHECKER PROCESS KEEPS EVERY SNAPSHOT UNTIL IT IS RELEASED — `updateSnapshot` releases
270
+ * nothing — and each holds a whole program and checker. Dropped unreleased, a kept session grew
271
+ * without bound: an `abide lsp` checker reached 59 GB in two hours of watcher-driven snapshots.
272
+ * Released the moment it is replaced, a question still being answered over it throws, so the
273
+ * release waits for the last call out.
274
+ */
275
+ private retired: Snapshot[] = []
276
+ private inFlight = 0
277
+
278
+ /**
279
+ * Marks a call in flight until the returned value is disposed (`using`); the last one out
280
+ * releases what was retired meanwhile. A caller holding a `Project` across calls (`projectWith`)
281
+ * holds the session for as long.
282
+ */
283
+ hold(): Disposable {
284
+ this.inFlight += 1
285
+ return {
286
+ [Symbol.dispose]: () => {
287
+ this.inFlight -= 1
288
+ if (this.inFlight === 0) this.releaseRetired()
289
+ },
290
+ }
291
+ }
292
+
293
+ private retire(snapshot: Snapshot): void {
294
+ this.retired.push(snapshot)
295
+ if (this.inFlight === 0) this.releaseRetired()
296
+ }
297
+
298
+ private releaseRetired(): void {
299
+ for (const snapshot of this.retired) {
300
+ // A process that has stopped answering rejects the release, and dies with what it held.
301
+ const release = snapshot.dispose().catch(() => {})
302
+ this.releasing.add(release)
303
+ void release.finally(() => this.releasing.delete(release))
304
+ }
305
+ this.retired = []
306
+ }
307
+
308
+ /** Releases sent and not yet answered, which `close` lets finish before ending the connection. */
309
+ private releasing = new Set<Promise<void>>()
253
310
 
254
311
  /**
255
312
  * Paths edited or deleted on disk since the last snapshot, or `everything`.
@@ -397,10 +454,12 @@ export class TypeSession {
397
454
  // after it: a dropped cache re-reads what the project already listed, and a shadow written
398
455
  // for a page in a directory it never listed is not among them.
399
456
  if (everything)
400
- await this.api.updateSnapshot({
401
- openProjects: [this.opened],
402
- fileChanges: { invalidateAll: true },
403
- })
457
+ this.retire(
458
+ await this.api.updateSnapshot({
459
+ openProjects: [this.opened],
460
+ fileChanges: { invalidateAll: true },
461
+ }),
462
+ )
404
463
  const snapshot = await this.api.updateSnapshot({
405
464
  openProjects: [this.opened],
406
465
  fileChanges: {
@@ -409,6 +468,8 @@ export class TypeSession {
409
468
  deleted: edits.deleted,
410
469
  },
411
470
  })
471
+ if (this.latestSnapshot !== undefined) this.retire(this.latestSnapshot)
472
+ this.latestSnapshot = snapshot
412
473
  this.latest = snapshot.getProjects()[0]
413
474
  return this.latest
414
475
  }
@@ -457,6 +518,7 @@ export class TypeSession {
457
518
  async projectWith(
458
519
  modules: readonly string[],
459
520
  ): Promise<Project | undefined> {
521
+ using _held = this.hold()
460
522
  const changed: string[] = []
461
523
  for (const path of modules) {
462
524
  const canonical = this.canonical(path)
@@ -475,7 +537,7 @@ export class TypeSession {
475
537
  if (found[at] === undefined) missing.push(modules[at] as string)
476
538
  return missing.length === 0
477
539
  ? project
478
- : this.currentProject(missing, [...missing])
540
+ : await this.currentProject(missing, [...missing])
479
541
  }
480
542
 
481
543
  /**
@@ -562,6 +624,7 @@ export class TypeSession {
562
624
  text?: string
563
625
  })[],
564
626
  ): Promise<{ answers: CheckerAnswer[]; warnings: Diagnosis[] }> {
627
+ using _held = this.hold()
565
628
  const answered = await this.classify(sources)
566
629
  for (let at = 0; at < sources.length; at += 1) {
567
630
  const source = sources[at] as (typeof sources)[number]
@@ -639,6 +702,7 @@ export class TypeSession {
639
702
  servers: projection.servers,
640
703
  scripts: projection.scripts,
641
704
  positions: projection.positions,
705
+ items: new Set(projection.items.values()),
642
706
  },
643
707
  })
644
708
  }
@@ -665,7 +729,7 @@ export class TypeSession {
665
729
  // a wrong page, with nothing about it saying so.
666
730
  if (sourceFile === undefined)
667
731
  throw new Error(
668
- `abide: ${source.path} is not inside the project ${this.tsconfig}, so no name in it can be typed (31.3). Give the app a tsconfig whose include reaches it.`,
732
+ `abide: ${source.path} is not inside the project ${this.tsconfig}, so no name in it can be typed (RULEBOOK 31.3). Give the app a tsconfig whose include reaches it.`,
669
733
  )
670
734
  const read = await readsIn(
671
735
  checker,
@@ -676,19 +740,25 @@ export class TypeSession {
676
740
  source.file,
677
741
  )
678
742
  answers[at] = read.answer
679
- for (const warning of read.warnings)
680
- warnings.push(
681
- diagnosisAt(
682
- source.path,
683
- source.text,
684
- query.map.toSource(
685
- query.map.shadowPath,
686
- warning.at,
687
- ),
688
- 'warning',
689
- warning.message,
690
- ),
743
+ for (const warning of read.warnings) {
744
+ const offset = query.map.toSource(
745
+ query.map.shadowPath,
746
+ warning.at,
747
+ )
748
+ if (
749
+ source.text === undefined ||
750
+ !isIgnored(source.text, offset, warning.message)
691
751
  )
752
+ warnings.push(
753
+ diagnosisAt(
754
+ source.path,
755
+ source.text,
756
+ offset,
757
+ 'warning',
758
+ warning.message,
759
+ ),
760
+ )
761
+ }
692
762
  }),
693
763
  )
694
764
  return { answers, warnings }
@@ -707,6 +777,7 @@ export class TypeSession {
707
777
  within: string,
708
778
  sources: readonly Omit<ParsedSource, 'quiet'>[],
709
779
  ): Promise<{ ambient: Diagnosis[]; unkeyed: Diagnosis[] }> {
780
+ using _held = this.hold()
710
781
  const ambient: Diagnosis[] = []
711
782
  const unkeyed: Diagnosis[] = []
712
783
  const project = await this.currentProject()
@@ -813,6 +884,7 @@ export class TypeSession {
813
884
  app: Pick<AppLayout, 'root' | 'addresses' | 'handlers'>,
814
885
  scripts = true,
815
886
  ): Promise<{ diagnoses: Diagnosis[]; refused: Set<string> }> {
887
+ using _held = this.hold()
816
888
  const within = app.root
817
889
  const created: string[] = []
818
890
  const changed: string[] = []
@@ -942,8 +1014,18 @@ export class TypeSession {
942
1014
  ),
943
1015
  ),
944
1016
  )
945
- const found = [...refused, ...(await Promise.all(reports)).flat()]
946
- found.sort(byPlace)
1017
+ const reported = [...refused, ...(await Promise.all(reports)).flat()]
1018
+ reported.sort(byPlace)
1019
+ // A component's props type is checked twice — in its `props` call and in what a tag is held
1020
+ // to (`checkedShadow`) — and both copies place a finding in it at the one place written.
1021
+ const found: Diagnosis[] = []
1022
+ const placed = new Set<string>()
1023
+ for (const diagnosis of reported) {
1024
+ const key = `${diagnosis.path}:${diagnosis.line}:${diagnosis.column}:${diagnosis.code}:${diagnosis.message}`
1025
+ if (placed.has(key)) continue
1026
+ placed.add(key)
1027
+ found.push(diagnosis)
1028
+ }
947
1029
  return { diagnoses: found, refused: refusing }
948
1030
  }
949
1031
 
@@ -976,9 +1058,9 @@ export class TypeSession {
976
1058
  * The members that may be written at `offset` in a `.abide`, or `undefined` where none can be.
977
1059
  *
978
1060
  * 38.98 — ASKED WHERE A HOVER IS (`askedAt`), so `config().` offers the fields 27.13 declared and
979
- * a name read by name offers its value's members (31.1). MEMBERS ALONE: at an identifier the
980
- * API rejects ("completion list needs auto imports"), which the language server answers as
981
- * nothing offered.
1061
+ * a name read by name offers its value's members (31.1). MEMBERS ALONE in a script or an
1062
+ * expression: at an identifier the API rejects ("completion list needs auto imports"), which
1063
+ * the language server answers as nothing offered. Markup is answered apart (38.117).
982
1064
  */
983
1065
  async complete(
984
1066
  path: string,
@@ -986,15 +1068,80 @@ export class TypeSession {
986
1068
  offset: number,
987
1069
  triggerCharacter?: string,
988
1070
  ): Promise<CompletionInfo | undefined> {
1071
+ using _held = this.hold()
1072
+ const template = templateContext(text, offset)
1073
+ if (template !== undefined)
1074
+ return await this.completeTemplate(path, template)
989
1075
  const at = this.askedAt(path, text, offset)
990
1076
  if (at === undefined) return undefined
991
- return at.project.checker.getCompletionsAtPosition(
1077
+ return await at.project.checker.getCompletionsAtPosition(
992
1078
  at.asked.path,
993
1079
  at.asked.position,
994
1080
  { triggerCharacter },
995
1081
  )
996
1082
  }
997
1083
 
1084
+ /**
1085
+ * 38.117 — what markup offers: a block keyword, a component in scope, or a component's props.
1086
+ *
1087
+ * A PROP IS ASKED OF THE LAST LOWERING, by the component's name: the tag being typed is text the
1088
+ * parser refuses, and the import the name resolves through is the same in the lowering before it.
1089
+ */
1090
+ private async completeTemplate(
1091
+ path: string,
1092
+ template: TemplateContext,
1093
+ ): Promise<CompletionInfo> {
1094
+ const entries: CompletionEntry[] = []
1095
+ if (template.kind !== 'attribute') {
1096
+ const kind =
1097
+ template.kind === 'block'
1098
+ ? CompletionItemKind.Keyword
1099
+ : CompletionItemKind.Class
1100
+ for (const name of template.offered) entries.push({ name, kind })
1101
+ return { isIncomplete: false, entries }
1102
+ }
1103
+ const lowered = this.loweredForInspect.get(this.keyOf(path))
1104
+ const project = this.latest
1105
+ if (lowered === undefined || project === undefined)
1106
+ return { isIncomplete: false, entries }
1107
+ const checker = project.checker
1108
+ const found = await checker.resolveName(
1109
+ template.tag,
1110
+ SymbolFlags.Value,
1111
+ { document: lowered.map.shadowPath, position: 0 },
1112
+ )
1113
+ const record =
1114
+ found === undefined
1115
+ ? undefined
1116
+ : await propsRecordOf(
1117
+ checker,
1118
+ await aliasedSymbol(checker, found),
1119
+ )
1120
+ if (record === undefined) return { isIncomplete: false, entries }
1121
+ const unwritten = (await checker.getPropertiesOfType(record)).filter(
1122
+ (prop) => !template.written.includes(prop.name),
1123
+ )
1124
+ for (const entry of await Promise.all(
1125
+ unwritten.map(async (prop) => {
1126
+ const type = await checker.getTypeOfSymbol(prop)
1127
+ return {
1128
+ name: prop.name,
1129
+ kind: CompletionItemKind.Property,
1130
+ detail:
1131
+ type === undefined
1132
+ ? undefined
1133
+ : await checker.typeToString(
1134
+ type,
1135
+ undefined,
1136
+ NodeBuilderFlags.NoTruncation,
1137
+ ),
1138
+ }
1139
+ }),
1140
+ ))
1141
+ entries.push(entry)
1142
+ return { isIncomplete: false, entries }
1143
+ }
1144
+
998
1145
  /**
999
1146
  * The hover signature, documentation and definitions of the name at `offset` in a `.abide`.
1000
1147
  *
@@ -1013,6 +1160,7 @@ export class TypeSession {
1013
1160
  text: string,
1014
1161
  offset: number,
1015
1162
  ): Promise<Inspection | undefined> {
1163
+ using _held = this.hold()
1016
1164
  const at = this.askedAt(path, text, offset)
1017
1165
  if (at === undefined) return undefined
1018
1166
  const { project, asked } = at
@@ -1061,6 +1209,181 @@ export class TypeSession {
1061
1209
  }
1062
1210
  }
1063
1211
 
1212
+ /**
1213
+ * Every place the name at `offset` in a `.abide` is written, across the files the checker holds,
1214
+ * or `undefined` where nothing is named there.
1215
+ *
1216
+ * 38.116 — ASKED OF EVERY PLACE THE CHECKER READS THE OFFSET AT (`ShadowMap.placesFrom`), because
1217
+ * the checked module rewrites a name read by name into another symbol's read: the projection is
1218
+ * where a reactive's markup uses are the declaration's references. A place is kept only where
1219
+ * both layers copied it from the author's text and that text is the name, so scaffolding a
1220
+ * lowering wrote is never a place to rename.
1221
+ *
1222
+ * AND ASKED AGAIN WHERE A REFERENCE IS TEXT THE CHECKED MODULE COPIES TWICE. A component's props
1223
+ * type is a second copy of its `props` call (`checkedShadow`), and a tag's keys are typed by
1224
+ * that copy: the checker answers them as another symbol's references, so a reference landing
1225
+ * on one copy is asked at every place the author's text is read.
1226
+ */
1227
+ async references(
1228
+ path: string,
1229
+ text: string,
1230
+ offset: number,
1231
+ ): Promise<References | undefined> {
1232
+ using _held = this.hold()
1233
+ const lowered = this.loweredForInspect.get(this.keyOf(path))
1234
+ const project = this.latest
1235
+ if (
1236
+ lowered === undefined ||
1237
+ lowered.text !== text ||
1238
+ project === undefined
1239
+ )
1240
+ return undefined
1241
+ const asked = lowered.map.placesFrom(offset)
1242
+ if (asked.length === 0) return undefined
1243
+ const askedAt = new Set<string>()
1244
+ for (const place of asked)
1245
+ askedAt.add(`${place.path}:${place.position}`)
1246
+ let name: Place | undefined
1247
+ let spelled = ''
1248
+ let renamable = true
1249
+ const seen = new Set<string>()
1250
+ const occurrences: Occurrence[] = []
1251
+ for (let index = 0; index < asked.length; index += 1) {
1252
+ const { path: file, position } = asked[index] as {
1253
+ path: string
1254
+ position: number
1255
+ }
1256
+ const sourceFile = await project.program.getSourceFile(file)
1257
+ if (sourceFile === undefined) continue
1258
+ const node = getTouchingPropertyName(sourceFile, position)
1259
+ if (node.kind !== SyntaxKind.Identifier) continue
1260
+ const written = node.getText(sourceFile)
1261
+ if (name === undefined) {
1262
+ const copied = this.copiedSpan(node, written)
1263
+ name = copied === undefined ? undefined : placeOfSpan(copied)
1264
+ spelled = written
1265
+ }
1266
+ const entries = await project.checker.getReferencedSymbolsForNode(
1267
+ node,
1268
+ position,
1269
+ )
1270
+ for (const entry of entries) {
1271
+ const definition = await entry.definition.resolve(project)
1272
+ if (definition === undefined) continue
1273
+ const declaredIn = definition.getSourceFile().fileName
1274
+ if (!this.authored(declaredIn)) renamable = false
1275
+ const declared = (definition as { name?: Node }).name
1276
+ const references = await Promise.all(
1277
+ entry.references.map((handle) => handle.resolve(project)),
1278
+ )
1279
+ for (const reference of references) {
1280
+ if (reference === undefined) continue
1281
+ const copied = this.copiedSpan(reference, written)
1282
+ if (copied === undefined) continue
1283
+ const declaration =
1284
+ declared !== undefined &&
1285
+ declared.pos === reference.pos &&
1286
+ declaredIn === reference.getSourceFile().fileName
1287
+ // Text the checker reads at more than one place is asked at every one.
1288
+ const copies = copied.map?.placesFrom(copied.start) ?? []
1289
+ if (copies.length > 2)
1290
+ for (const place of copies) {
1291
+ const at = `${place.path}:${place.position}`
1292
+ if (askedAt.has(at)) continue
1293
+ askedAt.add(at)
1294
+ asked.push(place)
1295
+ }
1296
+ const placed = placeOfSpan(copied)
1297
+ const key = `${placed.path}:${placed.start.line}:${placed.start.column}`
1298
+ if (seen.has(key)) continue
1299
+ seen.add(key)
1300
+ const holder = reference.parent
1301
+ const declaresHolder =
1302
+ definition.pos === holder.pos &&
1303
+ declaredIn === reference.getSourceFile().fileName
1304
+ const shorthand =
1305
+ holder.kind ===
1306
+ SyntaxKind.ShorthandPropertyAssignment ||
1307
+ (holder.kind === SyntaxKind.BindingElement &&
1308
+ (holder as { propertyName?: Node }).propertyName ===
1309
+ undefined)
1310
+ occurrences.push({
1311
+ ...placed,
1312
+ declaration,
1313
+ // A shorthand names a property and a binding at once, and renaming one
1314
+ // writes the other out beside it: the binding element declares the local
1315
+ // and the shorthand assignment the property.
1316
+ shorthand: !shorthand
1317
+ ? undefined
1318
+ : declaresHolder ===
1319
+ (holder.kind === SyntaxKind.BindingElement)
1320
+ ? 'local'
1321
+ : 'property',
1322
+ })
1323
+ }
1324
+ }
1325
+ }
1326
+ if (name === undefined) return undefined
1327
+ return { name, written: spelled, renamable, occurrences }
1328
+ }
1329
+
1330
+ /**
1331
+ * Whether a file the checker read is one an author wrote and can edit: neither a library's
1332
+ * declaration, nor a dependency's, nor scaffolding this session serves that stands for no `.abide`.
1333
+ */
1334
+ private authored(fileName: string): boolean {
1335
+ if (fileName.endsWith('.d.ts') || fileName.includes('/node_modules/'))
1336
+ return false
1337
+ return (
1338
+ ShadowMap.fileFor(this.canonical(fileName)) !== undefined ||
1339
+ !this.shadows.has(this.keyOf(fileName))
1340
+ )
1341
+ }
1342
+
1343
+ /**
1344
+ * Where a node the checker read stands in the file the author wrote, with that file's map where
1345
+ * it is a `.abide`, or `undefined` where the author did not write `name` there: in scaffolding,
1346
+ * or in a file this session serves.
1347
+ */
1348
+ private copiedSpan(node: Node, name: string): CopiedSpan | undefined {
1349
+ const file = node.getSourceFile()
1350
+ let start = node.getStart(file)
1351
+ let end = node.end
1352
+ // A string literal names a property by its text, inside the quotes.
1353
+ if (node.kind === SyntaxKind.StringLiteral) {
1354
+ start += 1
1355
+ end -= 1
1356
+ }
1357
+ const canonical = this.canonical(file.fileName)
1358
+ const abidePath = ShadowMap.fileFor(canonical)
1359
+ if (abidePath === undefined) {
1360
+ if (!this.authored(file.fileName)) return undefined
1361
+ if (file.text.slice(start, end) !== name) return undefined
1362
+ return {
1363
+ path: file.fileName,
1364
+ text: file.text,
1365
+ start,
1366
+ end,
1367
+ map: undefined,
1368
+ }
1369
+ }
1370
+ const lowered = this.loweredForInspect.get(this.keyOf(abidePath))
1371
+ const span = lowered?.map.copiedToSource(canonical, start, end)
1372
+ if (
1373
+ lowered === undefined ||
1374
+ span === undefined ||
1375
+ lowered.text.slice(span.start, span.end) !== name
1376
+ )
1377
+ return undefined
1378
+ return {
1379
+ path: abidePath,
1380
+ text: lowered.text,
1381
+ start: span.start,
1382
+ end: span.end,
1383
+ map: lowered.map,
1384
+ }
1385
+ }
1386
+
1064
1387
  /**
1065
1388
  * A span the checker read, placed in the file the author wrote.
1066
1389
  *
@@ -1120,6 +1443,13 @@ export class TypeSession {
1120
1443
  }
1121
1444
  ).client
1122
1445
  const child = client.process
1446
+ // A release still being written when the connection ends is a write after end, thrown
1447
+ // where nothing catches it. Bounded, for the same process that has stopped answering.
1448
+ if (this.releasing.size > 0)
1449
+ await Promise.race([
1450
+ Promise.allSettled(this.releasing),
1451
+ Bun.sleep(RELEASE_GRACE_MS),
1452
+ ])
1123
1453
  await client.close()
1124
1454
  child?.kill()
1125
1455
  }
@@ -1127,6 +1457,9 @@ export class TypeSession {
1127
1457
 
1128
1458
  const AMBIENT = 'abide-check.d.ts'
1129
1459
 
1460
+ // How long `close` waits on releases in flight before ending the connection under them.
1461
+ const RELEASE_GRACE_MS = 1000
1462
+
1130
1463
  // What the checker asks for when it resolves an import of `x.abide` (`standIn`).
1131
1464
  const DECLARATION_SUFFIX = '.abide.d.ts'
1132
1465
  // Beside the app's tsconfig, so `extends` and every path in it resolve as they would from there.
@@ -1189,6 +1522,45 @@ export type Inspection = {
1189
1522
  definitions: Place[]
1190
1523
  }
1191
1524
 
1525
+ /**
1526
+ * The name under a cursor and every place it is written.
1527
+ *
1528
+ * 38.116 — `name` is where the cursor's own name stands; `renamable` is false where any symbol it
1529
+ * names is declared somewhere an author does not write.
1530
+ */
1531
+ export type References = {
1532
+ name: Place
1533
+ /** The name as it is written. */
1534
+ written: string
1535
+ renamable: boolean
1536
+ occurrences: Occurrence[]
1537
+ }
1538
+
1539
+ /**
1540
+ * One place a name is written: whether it is where the name is declared, and where it is a
1541
+ * shorthand, which side of it a rename renames — the `local` binding or the `property`.
1542
+ */
1543
+ export type Occurrence = Place & {
1544
+ declaration: boolean
1545
+ shorthand: 'local' | 'property' | undefined
1546
+ }
1547
+
1548
+ /** A span of a file the author wrote, and the map of the `.abide` it is in, where it is in one. */
1549
+ type CopiedSpan = Span & {
1550
+ path: string
1551
+ text: string
1552
+ map: ShadowMap | undefined
1553
+ }
1554
+
1555
+ /** A `CopiedSpan` as lines and columns. */
1556
+ function placeOfSpan(span: CopiedSpan): Place {
1557
+ return {
1558
+ path: span.path,
1559
+ start: lineOf(span.text, span.start),
1560
+ end: lineOf(span.text, span.end),
1561
+ }
1562
+ }
1563
+
1192
1564
  /**
1193
1565
  * The props type a component's tag is held to, printed.
1194
1566
  *
@@ -1198,16 +1570,23 @@ async function propsTypeOf(
1198
1570
  checker: Checker,
1199
1571
  component: CheckedSymbol,
1200
1572
  ): Promise<string> {
1573
+ const record = await propsRecordOf(checker, component)
1574
+ return record === undefined
1575
+ ? 'any'
1576
+ : checker.typeToString(record, undefined, NodeBuilderFlags.NoTruncation)
1577
+ }
1578
+
1579
+ /** The record a component is called with — the props its tag is held to (33.4). */
1580
+ async function propsRecordOf(
1581
+ checker: Checker,
1582
+ component: CheckedSymbol,
1583
+ ): Promise<Type | undefined> {
1201
1584
  const type = await checker.getTypeOfSymbol(component)
1202
1585
  const call =
1203
1586
  type === undefined
1204
1587
  ? undefined
1205
1588
  : (await checker.getSignaturesOfType(type, SignatureKind.Call))[0]
1206
- const record =
1207
- call === undefined ? undefined : await checker.getParameterType(call, 0)
1208
- return record === undefined
1209
- ? 'any'
1210
- : checker.typeToString(record, undefined, NodeBuilderFlags.NoTruncation)
1589
+ return call === undefined ? undefined : checker.getParameterType(call, 0)
1211
1590
  }
1212
1591
 
1213
1592
  /**
@@ -82,6 +82,11 @@ export type AppLayout = {
82
82
  addresses: string[]
83
83
  /** What the tree refuses, each at its file; a build stops naming every one. */
84
84
  refused: Diagnosis[]
85
+ /**
86
+ * What the parser refuses of each `.abide` as the disk holds it, each at its file. Apart from
87
+ * `refused` because it is the TEXT's: an editor's check parses the text it holds instead (38.34).
88
+ */
89
+ unparsed: Diagnosis[]
85
90
  /** The resolver every import was followed with, whose answers it keeps. */
86
91
  resolve: (specifier: string, from: string) => string | undefined
87
92
  }
@@ -99,11 +104,19 @@ export async function appLayout(root: string): Promise<AppLayout> {
99
104
  const { abide, handlers, modules, published } = appFiles(root, refusals)
100
105
  await refuseModuleHooks(root, modules, refusals)
101
106
  const resolve = resolverIn()
107
+ const unparsed = new Refusals(root, true)
102
108
  const own = parseSources(
103
109
  await textsAt(abide.map((path) => `${root}/${path}`)),
110
+ unparsed,
111
+ )
112
+ const found = await withDependencies(
113
+ root,
114
+ own,
115
+ handlers,
116
+ resolve,
104
117
  refusals,
118
+ unparsed,
105
119
  )
106
- const found = await withDependencies(root, own, handlers, resolve, refusals)
107
120
  const invoked = componentGraph(found.sources, resolve).invoked
108
121
  const sources: PlacedSource[] = []
109
122
  for (const source of found.sources) {
@@ -150,6 +163,7 @@ export async function appLayout(root: string): Promise<AppLayout> {
150
163
  mounted: found.mounted,
151
164
  addresses: [...addresses].sort(),
152
165
  refused: refusals.found,
166
+ unparsed: unparsed.found,
153
167
  resolve,
154
168
  }
155
169
  }
@@ -182,6 +196,8 @@ export async function withDependencies(
182
196
  own: readonly string[],
183
197
  resolve: (specifier: string, from: string) => string | undefined,
184
198
  refusals: Refusals,
199
+ /** Where what the parser refuses of a file reached here goes, where apart from `refusals`. */
200
+ unparsed: Refusals = refusals,
185
201
  ): Promise<{
186
202
  sources: ParsedSource[]
187
203
  handlers: HandlerFile[]
@@ -215,7 +231,7 @@ export async function withDependencies(
215
231
  if ((thrown as { code?: unknown }).code === 'ENOENT') return
216
232
  throw thrown
217
233
  }
218
- for (const one of parseSources([{ path, text }], refusals)) {
234
+ for (const one of parseSources([{ path, text }], unparsed)) {
219
235
  byPath.set(one.path, one)
220
236
  all.push(one)
221
237
  }