@beyondwork/docx-react-component 1.0.75 → 1.0.77

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.
@@ -1,16 +1,35 @@
1
1
  /**
2
- * Slice 3 reference resolution.
2
+ * Reference resolution + position queries.
3
3
  *
4
- * Takes an agent-supplied `ScopeReference` hint + a live document and
5
- * returns either a resolved `ScopeHandle`, a list of candidate handles
6
- * (ambiguous), a detached placeholder (scopeId was once valid), or a
7
- * `not-found`.
4
+ * Two distinct surfaces live in this module now, matching the two
5
+ * distinct semantics (KI-P9 fix):
8
6
  *
9
- * Deterministic hint kinds `scope-id`, `semantic-path`, `offset`,
10
- * `range` all resolve live today. Natural-language hints return a
11
- * `partial` result with `confidence: "low"`; a richer NL matcher lives
12
- * in a later slice (the AI API will keep the function at status
13
- * `partial` until then).
7
+ * 1. `resolveReference(ref)`**identity lookup**. Takes a durable
8
+ * `ScopeReference` (`scope-id`, `semantic-path`, or
9
+ * `natural-language` hint) and returns the scope it names. Safe
10
+ * across mutations: `scope-id` survives text replaces; missing
11
+ * references return typed `not-found` / `detached`. Callers that
12
+ * hold a `ScopeReference` across a mutation window should only ever
13
+ * be holding one of these durable kinds.
14
+ *
15
+ * 2. `queryScopeAtPosition(at)` / `queryScopeInRange(from, to)` —
16
+ * **one-shot positional queries**. Return a `ScopeHandle | null`
17
+ * against the *current* document state. The caller's next use of
18
+ * the returned handle MUST be through its `scopeId` — the position
19
+ * is consumed by the query and never round-trips as a reference.
20
+ * This is the click-to-scope / hit-test / selection-to-scope
21
+ * surface. Positions that lived at time T1 are meaningless at time
22
+ * T2 after any intervening mutation, so these functions refuse to
23
+ * be part of the stored-reference vocabulary.
24
+ *
25
+ * Before 2026-04-24, `ScopeReference` had `offset` and `range` variants
26
+ * that flowed through `resolveReference` with the same
27
+ * `{status:"resolved", handle, confidence}` shape as identity lookups.
28
+ * That conflation is the KI-P9 trap — documented in
29
+ * `docs/testing/scopes.md §6c` and `KNOWN-ISSUES.md` KI-P9. Removed.
30
+ *
31
+ * Natural-language hints continue to resolve through `resolveReference`
32
+ * with `confidence: "low"`; richer NL matching is a later slice.
14
33
  */
15
34
 
16
35
  import type { CanonicalDocument } from "../../model/canonical-document.ts";
@@ -23,11 +42,28 @@ import { buildScopePositionMap, type ScopePositionRange } from "./position-map.t
23
42
  import { resolveScopeRange, scopeSpecificity } from "./scope-range.ts";
24
43
  import type { ScopeHandle } from "./semantic-scope-types.ts";
25
44
 
45
+ /**
46
+ * Durable references to a scope. Each kind is safe to cache / store /
47
+ * round-trip through a tool-call boundary and re-resolve after
48
+ * arbitrary mutations:
49
+ *
50
+ * - `scope-id` — identity primitive. Preserved through text edits;
51
+ * returns `not-found` / `detached` cleanly on delete.
52
+ * - `semantic-path` — structural path (`body/paragraph/5`). Stable
53
+ * when block structure doesn't change; returns `not-found` when
54
+ * the path no longer resolves. Fragile on KI-001-class fixtures
55
+ * where a neighbour block drop shifts all later indices by 1.
56
+ * - `natural-language` — substring heuristic. Always
57
+ * `confidence: "low"`; callers must surface that to humans.
58
+ *
59
+ * Positional references (`offset`, `range`) are **not** part of this
60
+ * union — see `queryScopeAtPosition` / `queryScopeInRange` for those.
61
+ * They are one-shot queries that return a `ScopeHandle | null`; the
62
+ * position never becomes a stored reference. Cf. KI-P9.
63
+ */
26
64
  export type ScopeReference =
27
65
  | { readonly kind: "scope-id"; readonly value: string }
28
66
  | { readonly kind: "semantic-path"; readonly path: readonly string[] }
29
- | { readonly kind: "offset"; readonly at: number }
30
- | { readonly kind: "range"; readonly from: number; readonly to: number }
31
67
  | { readonly kind: "natural-language"; readonly hint: string };
32
68
 
33
69
  export type ResolveReferenceResult =
@@ -161,50 +197,74 @@ function innermostContaining(
161
197
  return best?.entry ?? null;
162
198
  }
163
199
 
164
- function resolveByOffset(
200
+ /**
201
+ * Inputs for the one-shot positional query functions. Narrower than
202
+ * `ResolveReferenceInputs` because the query functions never need NL
203
+ * overlay labels — they're pure structural lookups.
204
+ */
205
+ export interface QueryScopePositionInputs {
206
+ readonly document:
207
+ | Pick<CanonicalDocument, "content" | "docId" | "review">
208
+ | CanonicalDocumentEnvelope;
209
+ readonly overlay?: WorkflowOverlay | null;
210
+ readonly scopes?: readonly EnumeratedScope[];
211
+ }
212
+
213
+ /**
214
+ * Return the innermost scope whose range contains `at` in the current
215
+ * document state, or `null` if no scope contains the position.
216
+ *
217
+ * **One-shot query, not a stored reference.** The returned handle is
218
+ * the durable object — its `scopeId` may be stored and re-resolved via
219
+ * `resolveReference({kind:"scope-id", value: handle.scopeId})`. The
220
+ * input position must NOT be stored; it is meaningful only against the
221
+ * document state it was queried against. See KI-P9 for the trap this
222
+ * separation closes.
223
+ *
224
+ * Precision: marker-backed handles (workflow / comment / revision
225
+ * scopes anchored by inline `scope_marker_*` nodes) are returned with
226
+ * full confidence; derived handles (paragraph / heading / list-item /
227
+ * field / table etc. enumerated from the canonical tree) are just as
228
+ * authoritative — the confidence distinction that previously lived on
229
+ * the `{status:"resolved", confidence}` return went away with
230
+ * `resolveReference`'s offset/range cases. Callers that need to know
231
+ * whether the hit is marker-backed can read `handle.provenance`.
232
+ */
233
+ export function queryScopeAtPosition(
165
234
  at: number,
166
- scopes: readonly EnumeratedScope[],
167
- positionMap: ReturnType<typeof buildScopePositionMap>,
168
- ): ResolveReferenceResult {
235
+ inputs: QueryScopePositionInputs,
236
+ ): ScopeHandle | null {
237
+ const scopes = scopesFor({ ...inputs, overlay: inputs.overlay ?? null });
238
+ const positionMap = buildScopePositionMap(inputs.document);
169
239
  const hit = innermostContaining(
170
240
  scopes,
171
241
  positionMap,
172
242
  (range) => range.from <= at && at <= range.to,
173
243
  );
174
- if (!hit) {
175
- return { status: "not-found", reason: `no scope contains offset ${at}` };
176
- }
177
- return {
178
- status: "resolved",
179
- handle: hit.handle,
180
- confidence: hit.handle.provenance === "marker-backed" ? "high" : "medium",
181
- };
244
+ return hit?.handle ?? null;
182
245
  }
183
246
 
184
- function resolveByRange(
247
+ /**
248
+ * Return the innermost scope that fully contains the range `[from, to]`
249
+ * in the current document state, or `null` if no single scope contains
250
+ * the whole range. Same one-shot-query semantics as
251
+ * `queryScopeAtPosition`.
252
+ */
253
+ export function queryScopeInRange(
185
254
  from: number,
186
255
  to: number,
187
- scopes: readonly EnumeratedScope[],
188
- positionMap: ReturnType<typeof buildScopePositionMap>,
189
- ): ResolveReferenceResult {
256
+ inputs: QueryScopePositionInputs,
257
+ ): ScopeHandle | null {
190
258
  const low = Math.min(from, to);
191
259
  const high = Math.max(from, to);
260
+ const scopes = scopesFor({ ...inputs, overlay: inputs.overlay ?? null });
261
+ const positionMap = buildScopePositionMap(inputs.document);
192
262
  const hit = innermostContaining(
193
263
  scopes,
194
264
  positionMap,
195
265
  (range) => range.from <= low && high <= range.to,
196
266
  );
197
- if (!hit) {
198
- return {
199
- status: "not-found",
200
- reason: `no scope fully contains range [${low}, ${high}]`,
201
- };
202
- }
203
- return {
204
- status: "resolved",
205
- handle: hit.handle,
206
- confidence: hit.handle.provenance === "marker-backed" ? "high" : "medium",
207
- };
267
+ return hit?.handle ?? null;
208
268
  }
209
269
 
210
270
  /**
@@ -329,10 +389,6 @@ export function resolveReference(
329
389
  return resolveByScopeId(reference.value, scopes, inputs.overlay, positionMap);
330
390
  case "semantic-path":
331
391
  return resolveBySemanticPath(reference.path, scopes);
332
- case "offset":
333
- return resolveByOffset(reference.at, scopes, positionMap);
334
- case "range":
335
- return resolveByRange(reference.from, reference.to, scopes, positionMap);
336
392
  case "natural-language":
337
393
  return resolveByNaturalLanguage(
338
394
  reference.hint,
@@ -137,6 +137,32 @@ export interface LoadDocxEditorSessionOptions {
137
137
  * inspect every marker.
138
138
  */
139
139
  stripCosmeticMarkers?: boolean;
140
+ /**
141
+ * Phase 2 bookmark-strip allowlist. When `stripCosmeticMarkers` is
142
+ * `true` (the default), the parser's reference scan retains
143
+ * bookmarks whose name is referenced by a `<w:hyperlink w:anchor>`
144
+ * or `<w:instrText>` (REF / PAGEREF / NOTEREF / TOC) AND any name
145
+ * listed here. Use this when the host depends on a stable
146
+ * host-authored bookmark name (e.g. `placeholder_party_name`,
147
+ * `signature_block_2`) that the parser's automatic scan cannot
148
+ * infer is load-bearing.
149
+ *
150
+ * Default: `[]` (only the automatic scan retains names).
151
+ *
152
+ * Always-retained, regardless of this list:
153
+ * - `_Toc*` (when any TOC field exists in the document)
154
+ * - `_Ref*` and any other name explicitly cited by a hyperlink
155
+ * anchor or REF/PAGEREF/NOTEREF instruction
156
+ * - `bw:scope:*` (workflow scope markers — converted to
157
+ * first-class scope markers by the parser before strip runs)
158
+ * - everything (defensive blanket-retain) when the document
159
+ * contains a `<w:dataBinding>` whose xpath could reference
160
+ * bookmarks via paths the scanner cannot statically analyze
161
+ *
162
+ * See `services/debug/docs/phase-2-bookmark-strip-audit-2026-04-24.md`
163
+ * for the corpus categorization that informed this contract.
164
+ */
165
+ retainedBookmarkNames?: ReadonlyArray<string>;
140
166
  }
141
167
 
142
168
  /**
@@ -540,7 +540,12 @@ export async function loadDocxSessionAsync(
540
540
  mediaParts,
541
541
  mainDocumentPath,
542
542
  chartPartLookup,
543
- { stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
543
+ {
544
+ stripCosmeticMarkers: options.stripCosmeticMarkers !== false,
545
+ ...(options.retainedBookmarkNames !== undefined
546
+ ? { retainedBookmarkNames: options.retainedBookmarkNames }
547
+ : {}),
548
+ },
544
549
  );
545
550
  } finally {
546
551
  if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
@@ -1313,7 +1318,12 @@ export function loadDocxSessionSync(
1313
1318
  mediaParts,
1314
1319
  mainDocumentPath,
1315
1320
  chartPartLookup,
1316
- { stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
1321
+ {
1322
+ stripCosmeticMarkers: options.stripCosmeticMarkers !== false,
1323
+ ...(options.retainedBookmarkNames !== undefined
1324
+ ? { retainedBookmarkNames: options.retainedBookmarkNames }
1325
+ : {}),
1326
+ },
1317
1327
  );
1318
1328
  } finally {
1319
1329
  if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
@@ -123,7 +123,7 @@ export function createFastTextEditLane(
123
123
  const fromRuntime = positionMap.pmToRuntime(fromPm);
124
124
  const toRuntime = positionMap.pmToRuntime(toPm);
125
125
 
126
- pushLaneDebug({
126
+ const debugEntry = pushLaneDebug({
127
127
  opId,
128
128
  intent: intent.kind,
129
129
  pmFrom: fromPm,
@@ -137,6 +137,7 @@ export function createFastTextEditLane(
137
137
  if (options.shouldBailBeforePredict?.(intent, fromRuntime, toRuntime)) {
138
138
  const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
139
139
  incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.bailBeforePredict);
140
+ markLaneDebugReconciled(debugEntry, ack.kind, true);
140
141
  options.probe?.markReconciled(opId, ack.kind);
141
142
  switch (ack.kind) {
142
143
  case "equivalent":
@@ -183,6 +184,7 @@ export function createFastTextEditLane(
183
184
  op.predictedSelectionHead = view.state.selection.head;
184
185
 
185
186
  const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
187
+ markLaneDebugReconciled(debugEntry, ack.kind, false);
186
188
  options.probe?.markReconciled(opId, ack.kind);
187
189
 
188
190
  switch (ack.kind) {
@@ -280,6 +282,14 @@ interface LaneDebugEntry {
280
282
  runtimeStorySize: number;
281
283
  fromRuntime: number;
282
284
  toRuntime: number;
285
+ /** Dispatch → reconcile observation. Filled by `markLaneDebugReconciled`. */
286
+ ackKind?: TextCommandAck["kind"];
287
+ /** Wall-clock ms between `pushLaneDebug` and `markLaneDebugReconciled`. */
288
+ reconcileMs?: number;
289
+ /** Whether the lane short-circuited to dispatch-only (no predicted TX). */
290
+ bailed?: boolean;
291
+ /** Wall-clock timestamp at push time — used to compute reconcileMs. */
292
+ startedAtMs: number;
283
293
  }
284
294
 
285
295
  declare global {
@@ -294,19 +304,48 @@ declare global {
294
304
  * buffer is capped at 200 entries; consumers can read it from the browser
295
305
  * console to diagnose cursor position mismatches between PM and the runtime.
296
306
  *
307
+ * Returns the pushed entry (or `null` when the buffer isn't enabled) so
308
+ * the caller can mutate it in place with ack-kind + reconcile timing
309
+ * via `markLaneDebugReconciled` once the runtime dispatch returns.
310
+ *
297
311
  * To enable in the browser console:
298
312
  * window.__DOCX_LANE_DEBUG__ = [];
299
313
  * Then type, then:
300
314
  * JSON.stringify(window.__DOCX_LANE_DEBUG__, null, 2)
301
315
  */
302
- function pushLaneDebug(entry: LaneDebugEntry): void {
303
- if (typeof window === "undefined") return;
316
+ function pushLaneDebug(
317
+ entry: Omit<LaneDebugEntry, "startedAtMs">,
318
+ ): LaneDebugEntry | null {
319
+ if (typeof window === "undefined") return null;
304
320
  const buffer = window.__DOCX_LANE_DEBUG__;
305
- if (!Array.isArray(buffer)) return;
306
- buffer.push(entry);
321
+ if (!Array.isArray(buffer)) return null;
322
+ const full: LaneDebugEntry = {
323
+ ...entry,
324
+ startedAtMs:
325
+ typeof performance !== "undefined" && typeof performance.now === "function"
326
+ ? performance.now()
327
+ : Date.now(),
328
+ };
329
+ buffer.push(full);
307
330
  if (buffer.length > 200) {
308
331
  buffer.splice(0, buffer.length - 200);
309
332
  }
333
+ return full;
334
+ }
335
+
336
+ function markLaneDebugReconciled(
337
+ entry: LaneDebugEntry | null,
338
+ ackKind: TextCommandAck["kind"],
339
+ bailed: boolean,
340
+ ): void {
341
+ if (!entry) return;
342
+ entry.ackKind = ackKind;
343
+ entry.bailed = bailed;
344
+ const now =
345
+ typeof performance !== "undefined" && typeof performance.now === "function"
346
+ ? performance.now()
347
+ : Date.now();
348
+ entry.reconcileMs = now - entry.startedAtMs;
310
349
  }
311
350
 
312
351
  function buildTxCompat(
@@ -12,6 +12,9 @@ export type PerfProbeKind =
12
12
  | "snapshot.navigation"
13
13
  | "pm.rebuild"
14
14
  | "pm.decorations"
15
+ | "pm.decorations.comments"
16
+ | "pm.decorations.revisions"
17
+ | "pm.decorations.workflow"
15
18
  | "pm.mount"
16
19
  | "shell.render"
17
20
  | "workspace.chrome"
@@ -20,9 +20,26 @@ import type {
20
20
  WorkflowScope,
21
21
  } from "../../api/public-types";
22
22
  import { MAIN_STORY_TARGET, storyTargetsEqual } from "../../api/public-types.ts";
23
+ import {
24
+ incrementInvalidationCounter,
25
+ recordPerfSample,
26
+ } from "./perf-probe";
23
27
  import type { PositionMap } from "./pm-position-map";
24
28
  import type { Node as PMNode } from "prosemirror-model";
25
29
 
30
+ /**
31
+ * Cheap wall-clock delta helper for sub-section probes. `performance.now`
32
+ * is always defined in the browser surface where this runs; the guard
33
+ * keeps headless Node contexts (tests) from throwing when the global
34
+ * isn't polyfilled.
35
+ */
36
+ function nowMs(): number {
37
+ return typeof performance !== "undefined" &&
38
+ typeof performance.now === "function"
39
+ ? performance.now()
40
+ : Date.now();
41
+ }
42
+
26
43
  type RailDecorationSpec = {
27
44
  railKind: "scope" | "candidate" | "blocked";
28
45
  className: string;
@@ -394,6 +411,8 @@ export function buildDecorations(
394
411
  const lockedPmRanges = collectLockedPmRanges(workflowLockedZones, activeStory, positionMap);
395
412
 
396
413
  // Walk comment threads and create inline decorations
414
+ const commentsStartMs = nowMs();
415
+ let commentCount = 0;
397
416
  if (commentModel) {
398
417
  for (const thread of commentModel.threads) {
399
418
  const cls = getCommentHighlightClass(
@@ -413,11 +432,18 @@ export function buildDecorations(
413
432
  "data-comment-id": thread.commentId,
414
433
  }),
415
434
  );
435
+ commentCount += 1;
416
436
  }
417
437
  }
418
438
  }
439
+ recordPerfSample("pm.decorations.comments", nowMs() - commentsStartMs);
440
+ if (commentCount > 0) {
441
+ incrementInvalidationCounter("pm.decorations.comments.count", commentCount);
442
+ }
419
443
 
420
444
  // Walk revision entries and create inline decorations.
445
+ const revisionsStartMs = nowMs();
446
+ let revisionCount = 0;
421
447
  // Deletion hiding in clean mode ALWAYS applies, even when showTrackedChanges is off.
422
448
  // Visual styling (underlines, colors) only applies when showTrackedChanges is on.
423
449
  if (revisionModel) {
@@ -442,6 +468,7 @@ export function buildDecorations(
442
468
  "data-revision-id": rev.revisionId,
443
469
  }),
444
470
  );
471
+ revisionCount += 1;
445
472
  }
446
473
  continue;
447
474
  }
@@ -477,6 +504,7 @@ export function buildDecorations(
477
504
  return el;
478
505
  }, { side: 1, key: `${rev.revisionId}-close` }),
479
506
  );
507
+ revisionCount += 1;
480
508
  } else if (rev.kind === "deletion") {
481
509
  decorations.push(
482
510
  Decoration.inline(pmFrom, pmTo, {
@@ -484,6 +512,7 @@ export function buildDecorations(
484
512
  "data-revision-id": rev.revisionId,
485
513
  }),
486
514
  );
515
+ revisionCount += 1;
487
516
  }
488
517
  continue;
489
518
  }
@@ -511,9 +540,16 @@ export function buildDecorations(
511
540
  "data-revision-id": rev.revisionId,
512
541
  }),
513
542
  );
543
+ revisionCount += 1;
514
544
  }
515
545
  }
546
+ recordPerfSample("pm.decorations.revisions", nowMs() - revisionsStartMs);
547
+ if (revisionCount > 0) {
548
+ incrementInvalidationCounter("pm.decorations.revisions.count", revisionCount);
549
+ }
516
550
 
551
+ const workflowStartMs = nowMs();
552
+ const workflowDecorationsBefore = decorations.length;
517
553
  if (effectiveWorkflowScopes.length > 0) {
518
554
  for (const scope of effectiveWorkflowScopes) {
519
555
  const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
@@ -655,6 +691,14 @@ export function buildDecorations(
655
691
  }, railRangeCache);
656
692
  }
657
693
  }
694
+ recordPerfSample("pm.decorations.workflow", nowMs() - workflowStartMs);
695
+ const workflowDecorationCount = decorations.length - workflowDecorationsBefore;
696
+ if (workflowDecorationCount > 0) {
697
+ incrementInvalidationCounter(
698
+ "pm.decorations.workflow.count",
699
+ workflowDecorationCount,
700
+ );
701
+ }
658
702
 
659
703
  return DecorationSet.create(doc, decorations);
660
704
  }