@oh-my-pi/hashline 17.3.7 → 17.4.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.4.0] - 2026-08-20
6
+
7
+ ### Added
8
+
9
+ - Added an opener-escape landing correction for insertions anchored on a construct's opening line to place shallower sibling constructs after the enclosing block rather than splitting the opener from its body.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed an issue where single-line replacements echoing attributes or decorators (such as `#[napi]` or `@Injectable()`) could lead to silently duplicated annotations.
14
+ - Increased the default snapshot-store path capacity from 30 to 256 to prevent early tags in wide sessions from aging out and triggering misleading "hash is not from this session" errors.
15
+
5
16
  ## [17.3.3] - 2026-08-14
6
17
 
7
18
  ### Fixed
@@ -170,6 +170,13 @@ export declare function afterInsertLandingShiftWarning(anchorLine: number, landi
170
170
  * the block's body".
171
171
  */
172
172
  export declare function blockInsertLandingShiftWarning(blockStart: number, closerLine: number, landingLine: number): string;
173
+ /**
174
+ * Plain `PUT >N:` anchored on a block-opener line with a shallower construct
175
+ * body: the landing was moved past the whole block — anchoring on an opener
176
+ * places the body between the opener and its first statement, a position a
177
+ * body at the opener's depth or above never intends.
178
+ */
179
+ export declare function afterInsertOpenerEscapeWarning(anchorLine: number, landingLine: number): string;
173
180
  /** `Recovery`: an external write matched a cached snapshot. */
174
181
  export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
175
182
  /** `Recovery`: a prior in-session edit advanced the hash. */
@@ -76,7 +76,7 @@ export declare abstract class SnapshotStore {
76
76
  abstract clear(): void;
77
77
  }
78
78
  export interface InMemorySnapshotStoreOptions {
79
- /** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
79
+ /** Maximum number of distinct paths tracked at once (default 256). LRU eviction. */
80
80
  maxPaths?: number;
81
81
  /** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
82
82
  maxVersionsPerPath?: number;
@@ -8,6 +8,16 @@
8
8
  * language yields no structural proof, so only evidence-complete textual
9
9
  * normalization remains available.
10
10
  */
11
+ import { type NodeSpan } from "@oh-my-pi/pi-natives";
12
+ export type { NodeSpan };
13
+ /**
14
+ * Named-node chain (innermost-first) containing `line`, excluding the file
15
+ * root: single-line nodes beginning on the line (attributes, decorators)
16
+ * first, then every enclosing construct. Empty when the language is unknown,
17
+ * the line is blank, or the source cannot parse — callers treat empty as
18
+ * "no structural evidence", never as evidence about the line.
19
+ */
20
+ export declare function nodeChain(lines: readonly string[], path: string, line: number): readonly NodeSpan[];
11
21
  /** Syntactic node boundaries outside a visible source range. */
12
22
  export declare function enclosingBoundaries(lines: readonly string[], path: string, startLine: number, endLine: number): readonly number[];
13
23
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "17.3.7",
4
+ "version": "17.4.0",
5
5
  "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -33,8 +33,8 @@
33
33
  "fmt": "biome format --write ."
34
34
  },
35
35
  "dependencies": {
36
- "@oh-my-pi/pi-natives": "17.3.7",
37
- "@oh-my-pi/pi-utils": "17.3.7"
36
+ "@oh-my-pi/pi-natives": "17.4.0",
37
+ "@oh-my-pi/pi-utils": "17.4.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/bun": "^1.3.14"
package/src/apply.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import { resolveClipboardEdits } from "./clipboard";
13
13
  import {
14
14
  afterInsertLandingShiftWarning,
15
+ afterInsertOpenerEscapeWarning,
15
16
  ambiguousBoundaryEchoMessage,
16
17
  ambiguousBoundaryPlacementMessage,
17
18
  blockInsertLandingShiftWarning,
@@ -22,7 +23,7 @@ import {
22
23
  UNRESOLVED_BLOCK_INTERNAL,
23
24
  UNRESOLVED_CLIPBOARD_INTERNAL,
24
25
  } from "./messages";
25
- import { enclosingBoundaries, parsesCleanly } from "./syntax";
26
+ import { enclosingBoundaries, nodeChain, parsesCleanly } from "./syntax";
26
27
  import { cloneCursor } from "./tokenizer";
27
28
  import type { Anchor, ApplyResult, Clipboard, Cursor, Edit } from "./types";
28
29
 
@@ -153,6 +154,51 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
153
154
  /** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
154
155
  export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
155
156
 
157
+ /**
158
+ * Grammar node kinds for attribute/decorator/annotation rows, per bundled
159
+ * tree-sitter grammar. Statements may repeat verbatim on adjacent lines by
160
+ * intent, but annotations on one item never do — an exact adjacent copy is a
161
+ * boundary echo. This classification is the extra evidence that lets
162
+ * one-sided echo normalization act on single-line ranges (the
163
+ * doc-restoration incident: a `PUT N.=N` landed one line high and its body
164
+ * restated the `#[napi]` surviving just below the range, duplicating the
165
+ * attribute — a result that PARSES, attributes being repeatable syntax, so
166
+ * neither the probe advisory nor the variant search could catch it).
167
+ *
168
+ * Kinds are grammar-blessed names, verified against the bundled parsers —
169
+ * not lexical guesses; `@media` in CSS or `@x` inside a string never
170
+ * classify. Extend per grammar as needed.
171
+ */
172
+ const ANNOTATION_NODE_KINDS: Record<string, true> = {
173
+ attribute_item: true, // rust `#[...]`
174
+ inner_attribute_item: true, // rust `#![...]`
175
+ decorator: true, // typescript/tsx/javascript/python
176
+ annotation: true, // kotlin, java `@Foo(...)`
177
+ marker_annotation: true, // java `@Override`
178
+ attribute_list: true, // c# `[Fact]`
179
+ };
180
+
181
+ /** File line `line` is exactly a single-line annotation node. */
182
+ function isAnnotationLine(fileLines: readonly string[], path: string, line: number): boolean {
183
+ return nodeChain(fileLines, path, line).some(
184
+ node => node.startLine === line && node.endLine === line && ANNOTATION_NODE_KINDS[node.kind] === true,
185
+ );
186
+ }
187
+
188
+ /** Every file line in the inclusive range is an annotation node. */
189
+ function isAnnotationEchoRun(
190
+ fileLines: readonly string[],
191
+ path: string | undefined,
192
+ first: number,
193
+ last: number,
194
+ ): boolean {
195
+ if (path === undefined) return false;
196
+ for (let line = first; line <= last; line++) {
197
+ if (!isAnnotationLine(fileLines, path, line)) return false;
198
+ }
199
+ return true;
200
+ }
201
+
156
202
  interface ReplacementGroup {
157
203
  /** Positions in the edit array of the payload inserts, in payload order. */
158
204
  insertIndices: number[];
@@ -325,18 +371,22 @@ interface TextualBoundaryNormalization {
325
371
  }
326
372
 
327
373
  /**
328
- * Normalize exact boundary echoes without interpreting language tokens.
374
+ * Normalize exact boundary echoes.
329
375
  *
330
376
  * Two-sided echoes are removed when stripping both copies leaves one payload
331
- * row per deleted range line. One-sided echoes on multi-line ranges are
332
- * removed when the remaining payload still covers the full range; an
333
- * under-filled one-sided echo is recorded as ambiguous so the syntax-probe
334
- * search gets first chance to resolve it, then rejected rather than silently
335
- * dropping unique range content.
377
+ * row per deleted range line. One-sided echoes are removed when the remaining
378
+ * payload still covers the full range — on multi-line ranges from line
379
+ * equality alone, on single-line ranges only when every echoed row is a
380
+ * grammar-classified annotation ({@link ANNOTATION_NODE_KINDS}), where an
381
+ * adjacent duplicate is never intentional. An under-filled one-sided echo is
382
+ * recorded as ambiguous so the syntax-probe search gets first chance to
383
+ * resolve it, then rejected rather than silently dropping unique range
384
+ * content.
336
385
  */
337
386
  function normalizeTextualBoundaryEchoes(
338
387
  edits: readonly AppliedEdit[],
339
388
  fileLines: readonly string[],
389
+ path: string | undefined,
340
390
  ): TextualBoundaryNormalization {
341
391
  const out: AppliedEdit[] = [];
342
392
  const warnings: string[] = [];
@@ -361,7 +411,10 @@ function normalizeTextualBoundaryEchoes(
361
411
  dropLeading = leading;
362
412
  dropTrailing = trailing;
363
413
  }
364
- } else if (leading > 0 && rangeLength > 1) {
414
+ } else if (
415
+ leading > 0 &&
416
+ (rangeLength > 1 || isAnnotationEchoRun(fileLines, path, group.startLine - leading, group.startLine - 1))
417
+ ) {
365
418
  if (group.payload.length - leading >= rangeLength) {
366
419
  dropLeading = leading;
367
420
  } else {
@@ -372,7 +425,10 @@ function normalizeTextualBoundaryEchoes(
372
425
  count: leading,
373
426
  });
374
427
  }
375
- } else if (trailing > 0 && rangeLength > 1) {
428
+ } else if (
429
+ trailing > 0 &&
430
+ (rangeLength > 1 || isAnnotationEchoRun(fileLines, path, group.endLine + 1, group.endLine + trailing))
431
+ ) {
376
432
  if (group.payload.length - trailing >= rangeLength) {
377
433
  dropTrailing = trailing;
378
434
  } else {
@@ -947,6 +1003,37 @@ function resolveShiftedLanding(
947
1003
  return landing === group.anchor ? undefined : { line: landing, crossed };
948
1004
  }
949
1005
 
1006
+ /**
1007
+ * Body shape required for an opener-escape relocation: the rows tile into
1008
+ * leading single-line nodes (comments, attributes) followed by one multi-line
1009
+ * construct reaching the last content row — i.e. the body parses standalone
1010
+ * as one self-contained `mod`/`fn`/`class` that can be moved past the block
1011
+ * it was mis-anchored into without re-parenting anything. Bare statements and
1012
+ * multi-statement bodies fail it and stay literal: an under-indented one-line
1013
+ * body more likely names the inside of the block.
1014
+ */
1015
+ function bodyIsRelocatableConstruct(rows: readonly string[], path: string): boolean {
1016
+ let last = rows.length;
1017
+ while (last > 0 && !hasNonWhitespace(rows[last - 1])) last--;
1018
+ if (last === 0) return false;
1019
+ let line = 1;
1020
+ while (line <= last) {
1021
+ if (!hasNonWhitespace(rows[line - 1])) {
1022
+ line++;
1023
+ continue;
1024
+ }
1025
+ const spans = nodeChain(rows, path, line);
1026
+ let end = 0;
1027
+ for (const span of spans) {
1028
+ if (span.startLine === line && span.endLine > end) end = span.endLine;
1029
+ }
1030
+ if (end === 0) return false; // no node begins here — body does not parse as items
1031
+ if (end >= last) return end > line; // final node must be one multi-line construct
1032
+ line = end + 1;
1033
+ }
1034
+ return false;
1035
+ }
1036
+
950
1037
  /**
951
1038
  * Resolve where a block-lowered after-insert anchored on the block's closing
952
1039
  * line should land given a body depth `target` deeper than that closer: just
@@ -990,14 +1077,17 @@ function resolveInwardLanding(
990
1077
  /**
991
1078
  * Slide mis-anchored after-insert hunks to the depth their body indentation
992
1079
  * claims: outward past the structural closer lines that follow the anchor
993
- * when the body is shallower, or for `insert_after_block N:` lowerings
994
- * inward across the block's trailing closers when the body is deeper than
995
- * the block's closing line. Returns the corrected edit list plus one warning
996
- * per shifted hunk.
1080
+ * when the body is shallower; for plain inserts anchored on a block opener,
1081
+ * past the whole block when the body is a balanced construct claiming a
1082
+ * depth strictly above the opener (syntax-probe verified via `path`); or for
1083
+ * `insert_after_block N:` lowerings — inward across the block's trailing
1084
+ * closers when the body is deeper than the block's closing line. Returns the
1085
+ * corrected edit list plus one warning per shifted hunk.
997
1086
  */
998
1087
  function repairAfterInsertLandings(
999
1088
  edits: readonly AppliedEdit[],
1000
1089
  fileLines: readonly string[],
1090
+ path: string | undefined,
1001
1091
  ): { edits: readonly AppliedEdit[]; warnings: string[] } {
1002
1092
  // Group plain (non-replacement) after-anchor inserts per authored hunk:
1003
1093
  // rows of one hunk share the anchor line and the patch header line.
@@ -1039,7 +1129,59 @@ function repairAfterInsertLandings(
1039
1129
  warnings.push(afterInsertLandingShiftWarning(group.anchor, outward.line, outward.crossed));
1040
1130
  continue;
1041
1131
  }
1042
- if (group.blockStart === undefined) continue;
1132
+ if (group.blockStart === undefined) {
1133
+ // Opener-escape: `PUT >N:` anchored on a line that OPENS a construct,
1134
+ // with a self-contained construct body claiming a column depth
1135
+ // strictly above the opener — a landing between the opener and its
1136
+ // first statement, which no such body can intend, yet one that can
1137
+ // parse (items are legal inside Rust fn bodies). Candidates are the
1138
+ // enclosing constructs' end lines from the node chain, innermost
1139
+ // first, kept only when the construct's own opening depth sits at or
1140
+ // above the body's claim (the body could be its sibling); the first
1141
+ // candidate whose relocated result passes the syntax probe wins.
1142
+ // Equal-depth bodies stay literal, matching the outward shift.
1143
+ if (path === undefined) continue;
1144
+ const anchorText = fileLines[group.anchor - 1] ?? "";
1145
+ const targetCols = indentColumns(target);
1146
+ if (targetCols >= indentColumns(anchorText)) continue;
1147
+ const chain = nodeChain(fileLines, path, group.anchor);
1148
+ if (!chain.some(node => node.startLine === group.anchor && node.endLine > group.anchor)) continue;
1149
+ const rows = group.members.map(idx => insertEditAt(edits, idx).text);
1150
+ if (!bodyIsRelocatableConstruct(rows, path)) continue;
1151
+ const candidates = [
1152
+ ...new Set(
1153
+ chain
1154
+ .filter(
1155
+ node =>
1156
+ node.endLine > group.anchor && indentColumns(fileLines[node.startLine - 1] ?? "") <= targetCols,
1157
+ )
1158
+ .map(node => node.endLine),
1159
+ ),
1160
+ ].sort((a, b) => a - b);
1161
+ for (const landing of candidates) {
1162
+ // Never relocate across another hunk's target; farther
1163
+ // candidates cross the same line, so stop outright.
1164
+ let blocked = false;
1165
+ for (const targeted of targetedLines) {
1166
+ if (targeted > group.anchor && targeted <= landing) {
1167
+ blocked = true;
1168
+ break;
1169
+ }
1170
+ }
1171
+ if (blocked) break;
1172
+ const trial = [...(out ?? edits)];
1173
+ for (const idx of group.members) {
1174
+ const edit = insertEditAt(trial, idx);
1175
+ trial[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line: landing } } };
1176
+ }
1177
+ if (parsesCleanly(path, materializeEdits(fileLines, trial).text)) {
1178
+ out = trial;
1179
+ warnings.push(afterInsertOpenerEscapeWarning(group.anchor, landing));
1180
+ break;
1181
+ }
1182
+ }
1183
+ continue;
1184
+ }
1043
1185
  const inward = resolveInwardLanding(group, target, group.blockStart, fileLines, targetedLines);
1044
1186
  if (inward === undefined) continue;
1045
1187
  retarget(group, inward);
@@ -1070,7 +1212,6 @@ export interface ApplyEditsOptions {
1070
1212
  interface Materialized {
1071
1213
  text: string;
1072
1214
  firstChangedLine: number | undefined;
1073
- warnings: string[];
1074
1215
  }
1075
1216
 
1076
1217
  /**
@@ -1080,7 +1221,6 @@ interface Materialized {
1080
1221
  * veto.
1081
1222
  */
1082
1223
  function materializeEdits(originalLines: readonly string[], edits: readonly AppliedEdit[]): Materialized {
1083
- const { edits: landed, warnings } = repairAfterInsertLandings(edits, originalLines);
1084
1224
  const fileLines = [...originalLines];
1085
1225
  const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
1086
1226
 
@@ -1093,7 +1233,7 @@ function materializeEdits(originalLines: readonly string[], edits: readonly Appl
1093
1233
  const bofLines: string[] = [];
1094
1234
  const eofLines: string[] = [];
1095
1235
  const anchorEdits: IndexedEdit[] = [];
1096
- landed.forEach((edit, idx) => {
1236
+ edits.forEach((edit, idx) => {
1097
1237
  if (edit.kind === "insert" && edit.cursor.kind === "bof") {
1098
1238
  bofLines.push(edit.text);
1099
1239
  } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
@@ -1157,7 +1297,7 @@ function materializeEdits(originalLines: readonly string[], edits: readonly Appl
1157
1297
  const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
1158
1298
  if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
1159
1299
 
1160
- return { text: fileLines.join("\n"), firstChangedLine, warnings };
1300
+ return { text: fileLines.join("\n"), firstChangedLine };
1161
1301
  }
1162
1302
 
1163
1303
  /**
@@ -1201,13 +1341,14 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
1201
1341
  );
1202
1342
  validateLineBounds(targetEdits, fileLines);
1203
1343
  const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines);
1204
- const normalized = normalizeTextualBoundaryEchoes(targetEdits, fileLines);
1205
- const leading = [...clipboardWarnings, ...indentationWarnings, ...normalized.warnings];
1344
+ const landed = repairAfterInsertLandings(targetEdits, fileLines, options.path);
1345
+ const normalized = normalizeTextualBoundaryEchoes(landed.edits, fileLines, options.path);
1346
+ const leading = [...clipboardWarnings, ...indentationWarnings, ...landed.warnings, ...normalized.warnings];
1206
1347
  const authoredResult = materializeEdits(fileLines, normalized.edits);
1207
1348
  const baselineParses = parsesCleanly(options.path, text);
1208
1349
  const authoredParses = parsesCleanly(options.path, authoredResult.text);
1209
1350
  const finish = (result: Materialized, warnings: string[]): ApplyResult => {
1210
- const merged = [...warnings, ...result.warnings];
1351
+ const merged = [...warnings];
1211
1352
  // Post-apply syntax advisory: the result stopped parsing while the
1212
1353
  // pre-edit text parsed, so this patch demonstrably introduced the
1213
1354
  // error. Catches misplacements no boundary variant can explain.
package/src/messages.ts CHANGED
@@ -459,6 +459,20 @@ export function blockInsertLandingShiftWarning(blockStart: number, closerLine: n
459
459
  return `PUT >${blockStart}*: body indented deeper than closing line ${closerLine}, so it was placed inside the block, after line ${landingLine}. \`PUT >N*\` lands AFTER the block at sibling depth — if inside was intended, use plain \`PUT >${closerLine}:\`.`;
460
460
  }
461
461
 
462
+ /**
463
+ * Plain `PUT >N:` anchored on a block-opener line with a shallower construct
464
+ * body: the landing was moved past the whole block — anchoring on an opener
465
+ * places the body between the opener and its first statement, a position a
466
+ * body at the opener's depth or above never intends.
467
+ */
468
+ export function afterInsertOpenerEscapeWarning(anchorLine: number, landingLine: number): string {
469
+ return (
470
+ `PUT >${anchorLine}: line ${anchorLine} opens a block, and the body's indentation claims a position ` +
471
+ `outside it, so the body was landed after line ${landingLine} (verified by the syntax probe). ` +
472
+ `To insert after a whole construct, anchor on its closing line or use \`PUT >N*:\`.`
473
+ );
474
+ }
475
+
462
476
  /** `Recovery`: an external write matched a cached snapshot. */
463
477
  export const RECOVERY_EXTERNAL_WARNING =
464
478
  "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
package/src/snapshots.ts CHANGED
@@ -111,7 +111,11 @@ export abstract class SnapshotStore {
111
111
  abstract clear(): void;
112
112
  }
113
113
 
114
- const DEFAULT_MAX_PATHS = 30;
114
+ // Wide sessions routinely touch far more than a few dozen files; evicting a
115
+ // path downgrades a genuinely in-session tag to the misleading "hash is not
116
+ // from this session" rejection. Retention is still bounded by
117
+ // DEFAULT_MAX_TOTAL_BYTES, so a high path count costs little.
118
+ const DEFAULT_MAX_PATHS = 256;
115
119
  const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
116
120
  /** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
117
121
  const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
@@ -124,7 +128,7 @@ function mergeSeenLines(snapshot: Snapshot, lines: Iterable<number> | undefined)
124
128
  }
125
129
 
126
130
  export interface InMemorySnapshotStoreOptions {
127
- /** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
131
+ /** Maximum number of distinct paths tracked at once (default 256). LRU eviction. */
128
132
  maxPaths?: number;
129
133
  /** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
130
134
  maxVersionsPerPath?: number;
package/src/syntax.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  * normalization remains available.
10
10
  */
11
11
 
12
- import { enclosingBlockBoundaries } from "@oh-my-pi/pi-natives";
12
+ import { enclosingBlockBoundaries, type NodeSpan, nodeChainAt } from "@oh-my-pi/pi-natives";
13
+
14
+ export type { NodeSpan };
13
15
 
14
16
  /** Parse-result cache keyed by content hash + path; FIFO-bounded. */
15
17
  const parseCache = new Map<string, boolean>();
@@ -17,6 +19,34 @@ const PARSE_CACHE_MAX = 256;
17
19
 
18
20
  const boundaryCache = new Map<string, readonly number[]>();
19
21
 
22
+ const chainCache = new Map<string, readonly NodeSpan[]>();
23
+
24
+ /**
25
+ * Named-node chain (innermost-first) containing `line`, excluding the file
26
+ * root: single-line nodes beginning on the line (attributes, decorators)
27
+ * first, then every enclosing construct. Empty when the language is unknown,
28
+ * the line is blank, or the source cannot parse — callers treat empty as
29
+ * "no structural evidence", never as evidence about the line.
30
+ */
31
+ export function nodeChain(lines: readonly string[], path: string, line: number): readonly NodeSpan[] {
32
+ const text = lines.join("\n");
33
+ const key = `${Bun.hash(text).toString(36)}:${text.length}:${path}:${line}`;
34
+ const cached = chainCache.get(key);
35
+ if (cached !== undefined) return cached;
36
+ let chain: readonly NodeSpan[];
37
+ try {
38
+ chain = nodeChainAt({ code: text, path, line }) ?? [];
39
+ } catch {
40
+ chain = [];
41
+ }
42
+ if (chainCache.size >= PARSE_CACHE_MAX) {
43
+ const oldest = chainCache.keys().next().value;
44
+ if (oldest !== undefined) chainCache.delete(oldest);
45
+ }
46
+ chainCache.set(key, chain);
47
+ return chain;
48
+ }
49
+
20
50
  /** Syntactic node boundaries outside a visible source range. */
21
51
  export function enclosingBoundaries(
22
52
  lines: readonly string[],