@jagadeesh52423/atlassian-mcp-server 1.3.0 → 1.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.
Files changed (47) hide show
  1. package/README.md +17 -8
  2. package/dist/constants.d.ts +1 -0
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +1 -0
  5. package/dist/constants.js.map +1 -1
  6. package/dist/formatters/annotations.d.ts +82 -0
  7. package/dist/formatters/annotations.d.ts.map +1 -0
  8. package/dist/formatters/annotations.js +441 -0
  9. package/dist/formatters/annotations.js.map +1 -0
  10. package/dist/formatters/index.d.ts +3 -0
  11. package/dist/formatters/index.d.ts.map +1 -1
  12. package/dist/formatters/index.js +2 -0
  13. package/dist/formatters/index.js.map +1 -1
  14. package/dist/formatters/markdown-to-adf.d.ts.map +1 -1
  15. package/dist/formatters/markdown-to-adf.js +79 -1
  16. package/dist/formatters/markdown-to-adf.js.map +1 -1
  17. package/dist/formatters/wiki-guard.d.ts +18 -0
  18. package/dist/formatters/wiki-guard.d.ts.map +1 -0
  19. package/dist/formatters/wiki-guard.js +66 -0
  20. package/dist/formatters/wiki-guard.js.map +1 -0
  21. package/dist/handlers/bitbucket-handlers.d.ts +2 -0
  22. package/dist/handlers/bitbucket-handlers.d.ts.map +1 -1
  23. package/dist/handlers/bitbucket-handlers.js +8 -6
  24. package/dist/handlers/bitbucket-handlers.js.map +1 -1
  25. package/dist/handlers/confluence-handlers.d.ts +4 -0
  26. package/dist/handlers/confluence-handlers.d.ts.map +1 -1
  27. package/dist/handlers/confluence-handlers.js +42 -1
  28. package/dist/handlers/confluence-handlers.js.map +1 -1
  29. package/dist/index.js +1 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/services/bitbucket.d.ts +2 -1
  32. package/dist/services/bitbucket.d.ts.map +1 -1
  33. package/dist/services/bitbucket.js +13 -1
  34. package/dist/services/bitbucket.js.map +1 -1
  35. package/dist/services/confluence.d.ts +50 -2
  36. package/dist/services/confluence.d.ts.map +1 -1
  37. package/dist/services/confluence.js +190 -4
  38. package/dist/services/confluence.js.map +1 -1
  39. package/dist/tools/bitbucket-tools.d.ts.map +1 -1
  40. package/dist/tools/bitbucket-tools.js +9 -1
  41. package/dist/tools/bitbucket-tools.js.map +1 -1
  42. package/dist/tools/confluence-tools.d.ts.map +1 -1
  43. package/dist/tools/confluence-tools.js +27 -2
  44. package/dist/tools/confluence-tools.js.map +1 -1
  45. package/dist/types.d.ts +27 -0
  46. package/dist/types.d.ts.map +1 -1
  47. package/package.json +1 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://badge.fury.io/js/@jagadeesh52423%2Fatlassian-mcp-server.svg)](https://www.npmjs.com/package/@jagadeesh52423/atlassian-mcp-server)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- An MCP (Model Context Protocol) server that gives AI assistants full access to Jira, Confluence, and Bitbucket Cloud APIs -- 84 tools covering deep CRUD across all three products.
6
+ An MCP (Model Context Protocol) server that gives AI assistants full access to Jira, Confluence, and Bitbucket Cloud APIs -- 86 tools covering deep CRUD across all three products.
7
7
 
8
8
  ## What It Does
9
9
 
@@ -12,8 +12,8 @@ This server acts as a bridge between any MCP-compatible AI client (Claude Deskto
12
12
  ### Key Features
13
13
 
14
14
  - **Confluence** (20 tools) -- Search, create, read, update, and delete pages. Manage spaces, child pages, comments (full CRUD), attachments, labels, and version history. Input accepts Markdown (auto-converted to ADF); output is native ADF JSON.
15
- - **Jira** (48 tools) -- Full issue lifecycle: search, CRUD, comments, transitions, attachments, issue links, worklogs, watchers, subtasks, labels, history, agile boards, sprints, versions, batch creation (up to 50 issues), dev status, epic linking, and user lookup.
16
- - **Bitbucket** (16 tools) -- Repository management, branches, commits, pull requests (create/update/list), PR comments with resolve/unresolve, and issue tracking.
15
+ - **Jira** (49 tools) -- Full issue lifecycle: search, CRUD, comments, transitions, attachments, issue links, worklogs, watchers, subtasks, labels, history, agile boards, sprints (incl. moving issues into a sprint), versions, batch creation (up to 50 issues), dev status, epic linking, and user lookup.
16
+ - **Bitbucket** (17 tools) -- Repository management, branches, commits, pull requests (create/update/list), PR comments with resolve/unresolve, and issue tracking.
17
17
  - **Smart Field Handling** -- AI-driven field suggestions during Jira transitions. Analyzes issue context to auto-suggest values for required fields (e.g., DB scripts, test cases), reducing manual input.
18
18
  - **Snapshot Safety Net** -- Automatic local snapshots of mutating operations (create/update/delete) with configurable retention, so you can recover from accidental changes.
19
19
 
@@ -179,7 +179,8 @@ Add one of the following to your Claude Desktop config:
179
179
  | `confluence_search_pages` | Search pages via CQL text query |
180
180
  | `confluence_get_page` | Get a page by ID (returns ADF JSON body) |
181
181
  | `confluence_create_page` | Create a page (accepts Markdown) |
182
- | `confluence_update_page` | Update a page (accepts Markdown) |
182
+ | `confluence_update_page` | Update a page (accepts Markdown). Re-anchors inline comments automatically; reports any it could not |
183
+ | `confluence_reattach_inline_comments` | Repair inline comments detached by an earlier update, using page history. `dryRun` defaults to `true` |
183
184
  | `confluence_delete_page` | Delete a page |
184
185
  | `confluence_get_spaces` | List all spaces |
185
186
  | `confluence_get_pages_by_space` | Get pages in a space |
@@ -196,7 +197,7 @@ Add one of the following to your Claude Desktop config:
196
197
  | `confluence_add_labels` | Add labels to a page |
197
198
  | `confluence_remove_label` | Remove a label |
198
199
 
199
- ### Jira (48 tools)
200
+ ### Jira (49 tools)
200
201
 
201
202
  | Category | Tools |
202
203
  |----------|-------|
@@ -210,14 +211,14 @@ Add one of the following to your Claude Desktop config:
210
211
  | **Subtasks** | `get_subtasks`, `create_subtask` |
211
212
  | **Labels** | `get_labels`, `add_labels`, `remove_labels` |
212
213
  | **History** | `get_issue_history` |
213
- | **Agile** | `get_agile_boards`, `get_board_issues`, `get_sprints`, `get_sprint_issues`, `create_sprint`, `update_sprint` |
214
+ | **Agile** | `get_agile_boards`, `get_board_issues`, `get_sprints`, `get_sprint_issues`, `create_sprint`, `update_sprint`, `move_issues_to_sprint` |
214
215
  | **Versions** | `get_project_versions`, `create_version`, `update_version` |
215
216
  | **Batch/Dev** | `batch_create_issues`, `get_dev_status` |
216
217
  | **Epic** | `link_to_epic` |
217
218
 
218
219
  All Jira tools are prefixed with `jira_`.
219
220
 
220
- ### Bitbucket (16 tools)
221
+ ### Bitbucket (17 tools)
221
222
 
222
223
  | Tool | Description |
223
224
  |------|-------------|
@@ -236,7 +237,14 @@ All Jira tools are prefixed with `jira_`.
236
237
  | `bitbucket_add_pr_comment` | Add a PR comment |
237
238
  | `bitbucket_update_pr_comment` | Update a PR comment |
238
239
  | `bitbucket_delete_pr_comment` | Delete a PR comment |
239
- | `bitbucket_resolve_pr_comment` | Resolve/unresolve a PR comment |
240
+ | `bitbucket_resolve_pr_comment` | Mark a PR comment thread as resolved |
241
+ | `bitbucket_unresolve_pr_comment` | Reopen (unresolve) a PR comment thread |
242
+
243
+ ### PR comment resolution (v1.3.0+)
244
+
245
+ - `bitbucket_resolve_pr_comment` / `bitbucket_unresolve_pr_comment` use Bitbucket's dedicated resolution sub-resource (`POST`/`DELETE .../comments/{id}/resolve`) and return the refreshed comment.
246
+ - **Top-level comments only** -- Bitbucket only allows resolving/unresolving top-level review comments, not replies. Calling these on a reply returns `Comment is not a top-level comment`.
247
+ - Comments returned by `bitbucket_get_pr_comments`, `bitbucket_resolve_pr_comment`, and `bitbucket_unresolve_pr_comment` now include a `resolved` boolean. When a comment is resolved, the single-comment responses also include `resolvedOn` (ISO timestamp) and `resolvedBy` (display name); these resolution details are not present in the bulk list response.
240
248
 
241
249
  ## Confluence Content Format (v1.2.0+)
242
250
 
@@ -245,6 +253,7 @@ Confluence pages and comments use **ADF (Atlassian Document Format)** natively:
245
253
  - **Input** -- `create_page`, `update_page`, `add_comment`, `update_comment` accept **Markdown**, which the server converts to ADF using the official `@atlaskit/editor-markdown-transformer`.
246
254
  - **Output** -- `get_page`, `get_comments` return ADF JSON (an object, not a string).
247
255
  - **Comments** -- `get_comments` returns **both footer (page-level) and inline (text-anchored) comments** by default. Use the `commentType` param (`footer` | `inline` | `all`, default `all`) to filter. Every comment carries a `type` field (`"footer"` or `"inline"`). Inline comments additionally include `anchoredText` (the highlighted page text the comment is attached to), `resolutionStatus` (e.g. `"open"`, `"resolved"`), `markerRef`, and `webuiLink`. _Backwards-compatibility note: callers that previously relied on `get_comments` returning footer comments only will now also receive inline comments under the default `all`; pass `commentType: "footer"` to restore the old footer-only behaviour. The new `type` field is additive and does not remove any existing field._
256
+ - **Inline comment anchors** -- An inline comment is not a separate resource: it anchors to page text via an `annotation` mark **inside the page body**. Any update that rebuilds the body therefore detaches every inline comment on the page. `update_page` guards against this by reading the live body first and re-anchoring each marker onto text that survived the edit, then reporting `inlineComments: { preserved, dropped, ambiguous }`. A comment whose text you reworded or deleted appears in `dropped`; one whose text now occurs in more than one place appears in `ambiguous`, because the server refuses to pick an occurrence — a wrong guess silently moves a reviewer's question onto unrelated text while reporting success. Either way the comment stays on the page but unattached, which is exactly what Confluence does today. **Prefer leaving commented sentences intact.** To repair comments detached by an earlier update (including ones made before this feature existed), use `confluence_reattach_inline_comments`: it walks page history to find where each orphan was anchored, re-marks only unambiguous matches, and reports the rest rather than guessing. It never alters page text, and searches back at most 50 versions. Anchors are keyed by surrounding content (table header + first-column value, or block type + text) rather than by position, so they survive rows and paragraphs being inserted above them — which is what lets a comment on a repeated field name like `unique_id` resolve even though that text appears ten times on the page. Note that `anchoredText` from `get_comments` is truncated by Confluence at ~250 characters (252 observed); the history lookup recovers the full anchor.
248
257
  - **Inline images** -- Not currently supported via ADF media nodes (requires Atlassian Media API tokens). Use `confluence_add_attachment` instead.
249
258
 
250
259
  ## Project Structure
@@ -48,6 +48,7 @@ export declare const ERROR_MESSAGES: {
48
48
  export declare const ICONS: {
49
49
  readonly SUCCESS: "✅";
50
50
  readonly ERROR: "❌";
51
+ readonly WARNING: "⚠️";
51
52
  readonly SUGGESTION: "💡";
52
53
  readonly TICKET: "🎫";
53
54
  readonly SUMMARY: "📝";
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,eAAO,MAAM,UAAU;;;;;CAKb,CAAC;AAGX,eAAO,MAAM,QAAQ;;;;;;;;CAQX,CAAC;AAGX,eAAO,MAAM,aAAa;;;;;;;;;;;;CAiBhB,CAAC;AAGX,eAAO,MAAM,WAAW;;;CAGd,CAAC;AAGX,eAAO,MAAM,aAAa;;;;;CAKhB,CAAC;AAGX,eAAO,MAAM,cAAc;;oCAEF,MAAM;kCACR,MAAM;CACnB,CAAC;AAGX,eAAO,MAAM,KAAK;;;;;;;;;;;;;;CAcR,CAAC"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,eAAO,MAAM,UAAU;;;;;CAKb,CAAC;AAGX,eAAO,MAAM,QAAQ;;;;;;;;CAQX,CAAC;AAGX,eAAO,MAAM,aAAa;;;;;;;;;;;;CAiBhB,CAAC;AAGX,eAAO,MAAM,WAAW;;;CAGd,CAAC;AAGX,eAAO,MAAM,aAAa;;;;;CAKhB,CAAC;AAGX,eAAO,MAAM,cAAc;;oCAEF,MAAM;kCACR,MAAM;CACnB,CAAC;AAGX,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;CAeR,CAAC"}
package/dist/constants.js CHANGED
@@ -58,6 +58,7 @@ export const ERROR_MESSAGES = {
58
58
  export const ICONS = {
59
59
  SUCCESS: '✅',
60
60
  ERROR: '❌',
61
+ WARNING: '⚠️',
61
62
  SUGGESTION: '💡',
62
63
  TICKET: '🎫',
63
64
  SUMMARY: '📝',
@@ -1 +1 @@
1
- {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,sBAAsB;AACtB,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,wBAAwB,EAAE,EAAE;IAC5B,kBAAkB,EAAE,EAAE;IACtB,uBAAuB,EAAE,EAAE;IAC3B,iBAAiB,EAAE,EAAE;CACb,CAAC;AAEX,iBAAiB;AACjB,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,WAAW,EAAE,MAAM;IACnB,eAAe,EAAE,MAAM;IACvB,gBAAgB,EAAE,MAAM;IACxB,oBAAoB,EAAE,KAAK;IAC3B,wBAAwB,EAAE,OAAO;IACjC,kBAAkB,EAAE,MAAM;IAC1B,qBAAqB,EAAE,MAAM;CACrB,CAAC;AAEX,gBAAgB;AAChB,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,aAAa;IACb,kBAAkB,EAAE,wBAAwB;IAC5C,yBAAyB,EAAE,+BAA+B;IAC1D,gBAAgB,EAAE,sBAAsB;IAExC,OAAO;IACP,WAAW,EAAE,wBAAwB;IACrC,UAAU,EAAE,mBAAmB;IAC/B,YAAY,EAAE,qBAAqB;IACnC,gBAAgB,EAAE,yBAAyB;IAC3C,eAAe,EAAE,uBAAuB;IACxC,oBAAoB,EAAE,2BAA2B;IACjD,eAAe,EAAE,wBAAwB;IAEzC,YAAY;IACZ,kBAAkB,EAAE,+BAA+B;CAC3C,CAAC;AAEX,sBAAsB;AACtB,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,aAAa,EAAE,qDAAqD;IACpE,YAAY,EAAE,CAAC,SAAS,EAAE,aAAa,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,CAAC;CAC5E,CAAC;AAEX,4BAA4B;AAC5B,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,UAAU,EAAE,YAAY;IACxB,IAAI,EAAE,MAAM;IACZ,SAAS,EAAE,WAAW;IACtB,SAAS,EAAE,WAAW;CACd,CAAC;AAEX,iBAAiB;AACjB,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,kBAAkB,EAAE,iDAAiD;IACrE,cAAc,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,mBAAmB,IAAI,EAAE;IAC3D,YAAY,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,iBAAiB,IAAI,EAAE;CAC/C,CAAC;AAEX,wCAAwC;AACxC,MAAM,CAAC,MAAM,KAAK,GAAG;IACnB,OAAO,EAAE,GAAG;IACZ,KAAK,EAAE,GAAG;IACV,UAAU,EAAE,IAAI;IAChB,MAAM,EAAE,IAAI;IACZ,OAAO,EAAE,IAAI;IACb,MAAM,EAAE,IAAI;IACZ,IAAI,EAAE,IAAI;IACV,WAAW,EAAE,IAAI;IACjB,IAAI,EAAE,IAAI;IACV,QAAQ,EAAE,IAAI;IACd,MAAM,EAAE,KAAK;IACb,UAAU,EAAE,IAAI;IAChB,QAAQ,EAAE,IAAI;CACN,CAAC"}
1
+ {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,sBAAsB;AACtB,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,wBAAwB,EAAE,EAAE;IAC5B,kBAAkB,EAAE,EAAE;IACtB,uBAAuB,EAAE,EAAE;IAC3B,iBAAiB,EAAE,EAAE;CACb,CAAC;AAEX,iBAAiB;AACjB,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,WAAW,EAAE,MAAM;IACnB,eAAe,EAAE,MAAM;IACvB,gBAAgB,EAAE,MAAM;IACxB,oBAAoB,EAAE,KAAK;IAC3B,wBAAwB,EAAE,OAAO;IACjC,kBAAkB,EAAE,MAAM;IAC1B,qBAAqB,EAAE,MAAM;CACrB,CAAC;AAEX,gBAAgB;AAChB,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,aAAa;IACb,kBAAkB,EAAE,wBAAwB;IAC5C,yBAAyB,EAAE,+BAA+B;IAC1D,gBAAgB,EAAE,sBAAsB;IAExC,OAAO;IACP,WAAW,EAAE,wBAAwB;IACrC,UAAU,EAAE,mBAAmB;IAC/B,YAAY,EAAE,qBAAqB;IACnC,gBAAgB,EAAE,yBAAyB;IAC3C,eAAe,EAAE,uBAAuB;IACxC,oBAAoB,EAAE,2BAA2B;IACjD,eAAe,EAAE,wBAAwB;IAEzC,YAAY;IACZ,kBAAkB,EAAE,+BAA+B;CAC3C,CAAC;AAEX,sBAAsB;AACtB,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,aAAa,EAAE,qDAAqD;IACpE,YAAY,EAAE,CAAC,SAAS,EAAE,aAAa,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,CAAC;CAC5E,CAAC;AAEX,4BAA4B;AAC5B,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,UAAU,EAAE,YAAY;IACxB,IAAI,EAAE,MAAM;IACZ,SAAS,EAAE,WAAW;IACtB,SAAS,EAAE,WAAW;CACd,CAAC;AAEX,iBAAiB;AACjB,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,kBAAkB,EAAE,iDAAiD;IACrE,cAAc,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,mBAAmB,IAAI,EAAE;IAC3D,YAAY,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,iBAAiB,IAAI,EAAE;CAC/C,CAAC;AAEX,wCAAwC;AACxC,MAAM,CAAC,MAAM,KAAK,GAAG;IACnB,OAAO,EAAE,GAAG;IACZ,KAAK,EAAE,GAAG;IACV,OAAO,EAAE,IAAI;IACb,UAAU,EAAE,IAAI;IAChB,MAAM,EAAE,IAAI;IACZ,OAAO,EAAE,IAAI;IACb,MAAM,EAAE,IAAI;IACZ,IAAI,EAAE,IAAI;IACV,WAAW,EAAE,IAAI;IACjB,IAAI,EAAE,IAAI;IACV,QAAQ,EAAE,IAAI;IACd,MAAM,EAAE,KAAK;IACb,UAAU,EAAE,IAAI;IAChB,QAAQ,EAAE,IAAI;CACN,CAAC"}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Preserves Confluence inline comments across a page update.
3
+ *
4
+ * An inline comment is not a separate resource — it is an `annotation` mark
5
+ * (`annotationType: "inlineComment"`, `id` = the comment's marker ref) carried
6
+ * by the text nodes it covers. Markdown cannot express those marks, so writing
7
+ * a body built from markdown deletes every marker and detaches every inline
8
+ * comment on the page.
9
+ *
10
+ * The fix is to read the marks off the live body first and re-anchor them onto
11
+ * the new document by matching the annotated text.
12
+ */
13
+ export interface Annotation {
14
+ id: string;
15
+ annotationType: string;
16
+ /** The text the comment was anchored to, concatenated in document order. */
17
+ text: string;
18
+ }
19
+ /**
20
+ * Collect every inline-comment annotation in `doc`, in document order.
21
+ * Text spanning several nodes (a comment covering bold text, say) is
22
+ * concatenated under one id.
23
+ */
24
+ export declare function collectAnnotations(doc: any): Annotation[];
25
+ /**
26
+ * Re-apply `annotations` to `doc`, matching on the annotated text.
27
+ *
28
+ * An anchor whose text occurs more than once is reported in `ambiguous` and
29
+ * left unmarked. Picking an occurrence would move a reviewer's comment onto
30
+ * unrelated text while reporting success; leaving it unmarked reproduces
31
+ * exactly what Confluence does today, so it costs nothing and stays visible.
32
+ *
33
+ * ponytail: exact substring within a single text node. A comment whose text was
34
+ * reworded, or whose span is broken up by other marks (`**bold** inside the
35
+ * selection`), lands in `dropped`. Upgrade path if that bites: match across
36
+ * adjacent text nodes, then fuzzy/longest-common-substring.
37
+ */
38
+ export declare function reapplyAnnotations(doc: any, annotations: Annotation[]): {
39
+ doc: any;
40
+ preserved: Annotation[];
41
+ dropped: Annotation[];
42
+ ambiguous: Annotation[];
43
+ };
44
+ /**
45
+ * Where an annotation sat in an earlier version of a page, described by
46
+ * surrounding content rather than by index so it survives edits above it.
47
+ */
48
+ export type Locator = {
49
+ kind: 'tableCell';
50
+ header: string[];
51
+ rowKey: string;
52
+ cell: number;
53
+ text: string;
54
+ annotationType: string;
55
+ } | {
56
+ kind: 'block';
57
+ blockType: string;
58
+ blockText: string;
59
+ text: string;
60
+ annotationType: string;
61
+ };
62
+ /**
63
+ * Describe where `markerRef` is anchored in `doc`. Returns null if absent.
64
+ *
65
+ * Tables are keyed by header row + the row's first-column value, so a comment
66
+ * on a field-reference row still resolves after rows are added or reordered
67
+ * above it — an index would not survive that.
68
+ */
69
+ export declare function locateAnnotation(doc: any, markerRef: string): Locator | null;
70
+ /**
71
+ * Re-attach `markerRef` to `doc` at the position `locator` describes.
72
+ *
73
+ * 'ambiguous' means several equally-good hosts matched; the caller must decide,
74
+ * because guessing silently moves a reviewer's comment onto unrelated text.
75
+ * That is not hypothetical — anchor text "unique_id" occurs in ten text nodes
76
+ * of a real page whose true anchor was one specific table cell.
77
+ */
78
+ export declare function applyLocator(doc: any, locator: Locator, markerRef: string): {
79
+ doc: any;
80
+ status: 'exact' | 'ambiguous' | 'lost' | 'unmatchable';
81
+ };
82
+ //# sourceMappingURL=annotations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"annotations.d.ts","sourceRoot":"","sources":["../../src/formatters/annotations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;IACvB,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;CACd;AAwBD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,GAAG,GAAG,UAAU,EAAE,CAqBzD;AAgCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,GAAG,EACR,WAAW,EAAE,UAAU,EAAE,GACxB;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,SAAS,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,UAAU,EAAE,CAAC;IAAC,SAAS,EAAE,UAAU,EAAE,CAAA;CAAE,CAwBvF;AAID;;;GAGG;AACH,MAAM,MAAM,OAAO,GACf;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,GAC3G;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAAC;AAyBlG;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAmB5E;AA6BD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,MAAM,GAChB;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,OAAO,GAAG,WAAW,GAAG,MAAM,GAAG,aAAa,CAAA;CAAE,CAkBtE"}
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Preserves Confluence inline comments across a page update.
3
+ *
4
+ * An inline comment is not a separate resource — it is an `annotation` mark
5
+ * (`annotationType: "inlineComment"`, `id` = the comment's marker ref) carried
6
+ * by the text nodes it covers. Markdown cannot express those marks, so writing
7
+ * a body built from markdown deletes every marker and detaches every inline
8
+ * comment on the page.
9
+ *
10
+ * The fix is to read the marks off the live body first and re-anchor them onto
11
+ * the new document by matching the annotated text.
12
+ */
13
+ import { pathToFileURL } from 'node:url';
14
+ const isAnnotation = (m) => m?.type === 'annotation' && m?.attrs?.id;
15
+ /**
16
+ * Only this id blocks re-marking — Confluence allows several comments on the
17
+ * same phrase, so an unrelated annotation must not hide the text from another.
18
+ */
19
+ const hasAnnotationId = (n, id) => (n?.marks ?? []).some((m) => isAnnotation(m) && m.attrs.id === id);
20
+ /**
21
+ * Occurrences of `ann.text` that could take the mark. Counts per occurrence,
22
+ * not per node: one text node saying "set attributes before reading attributes"
23
+ * offers two candidates, and picking either silently relocates the comment.
24
+ */
25
+ function countCandidates(node, ann) {
26
+ if (node?.type === 'text') {
27
+ if (typeof node.text !== 'string' || hasAnnotationId(node, ann.id))
28
+ return 0;
29
+ return node.text.split(ann.text).length - 1;
30
+ }
31
+ return (node?.content ?? []).reduce((sum, c) => sum + countCandidates(c, ann), 0);
32
+ }
33
+ /**
34
+ * Collect every inline-comment annotation in `doc`, in document order.
35
+ * Text spanning several nodes (a comment covering bold text, say) is
36
+ * concatenated under one id.
37
+ */
38
+ export function collectAnnotations(doc) {
39
+ const byId = new Map();
40
+ const walk = (node) => {
41
+ if (!node || typeof node !== 'object')
42
+ return;
43
+ if (node.type === 'text' && typeof node.text === 'string') {
44
+ for (const m of node.marks ?? []) {
45
+ if (!isAnnotation(m))
46
+ continue;
47
+ const found = byId.get(m.attrs.id);
48
+ if (found)
49
+ found.text += node.text;
50
+ else
51
+ byId.set(m.attrs.id, {
52
+ id: m.attrs.id,
53
+ annotationType: m.attrs.annotationType ?? 'inlineComment',
54
+ text: node.text,
55
+ });
56
+ }
57
+ }
58
+ for (const child of node.content ?? [])
59
+ walk(child);
60
+ };
61
+ walk(doc);
62
+ return [...byId.values()];
63
+ }
64
+ /** Split `node` at `at` so the annotated slice carries the extra mark. */
65
+ function splitText(node, at, ann) {
66
+ const before = node.text.slice(0, at);
67
+ const after = node.text.slice(at + ann.text.length);
68
+ const mark = { type: 'annotation', attrs: { id: ann.id, annotationType: ann.annotationType } };
69
+ return [
70
+ ...(before ? [{ ...node, text: before }] : []),
71
+ { ...node, text: ann.text, marks: [...(node.marks ?? []), mark] },
72
+ ...(after ? [{ ...node, text: after }] : []),
73
+ ];
74
+ }
75
+ /** Re-anchor one annotation onto the first unannotated text node containing its text. */
76
+ function applyOne(node, ann, state) {
77
+ if (state.applied || !node || !Array.isArray(node.content))
78
+ return node;
79
+ const out = [];
80
+ for (const child of node.content) {
81
+ if (!state.applied && child?.type === 'text' && typeof child.text === 'string' && !hasAnnotationId(child, ann.id)) {
82
+ const at = child.text.indexOf(ann.text);
83
+ if (at !== -1) {
84
+ state.applied = true;
85
+ out.push(...splitText(child, at, ann));
86
+ continue;
87
+ }
88
+ }
89
+ out.push(state.applied ? child : applyOne(child, ann, state));
90
+ }
91
+ return { ...node, content: out };
92
+ }
93
+ /**
94
+ * Re-apply `annotations` to `doc`, matching on the annotated text.
95
+ *
96
+ * An anchor whose text occurs more than once is reported in `ambiguous` and
97
+ * left unmarked. Picking an occurrence would move a reviewer's comment onto
98
+ * unrelated text while reporting success; leaving it unmarked reproduces
99
+ * exactly what Confluence does today, so it costs nothing and stays visible.
100
+ *
101
+ * ponytail: exact substring within a single text node. A comment whose text was
102
+ * reworded, or whose span is broken up by other marks (`**bold** inside the
103
+ * selection`), lands in `dropped`. Upgrade path if that bites: match across
104
+ * adjacent text nodes, then fuzzy/longest-common-substring.
105
+ */
106
+ export function reapplyAnnotations(doc, annotations) {
107
+ let out = doc;
108
+ const preserved = [];
109
+ const dropped = [];
110
+ const ambiguous = [];
111
+ for (const ann of annotations) {
112
+ if (!ann.text.trim()) {
113
+ dropped.push(ann);
114
+ continue;
115
+ }
116
+ if (countCandidates(out, ann) > 1) {
117
+ ambiguous.push(ann);
118
+ continue;
119
+ }
120
+ const state = { applied: false };
121
+ const next = applyOne(out, ann, state);
122
+ if (state.applied) {
123
+ out = next;
124
+ preserved.push(ann);
125
+ }
126
+ else {
127
+ dropped.push(ann);
128
+ }
129
+ }
130
+ return { doc: out, preserved, dropped, ambiguous };
131
+ }
132
+ const TABLE = 'table';
133
+ /** Concatenated text of the nodes under `node` carrying `markerRef`. */
134
+ function annotatedText(node, markerRef) {
135
+ if (node?.type === 'text') {
136
+ return (node.marks ?? []).some((m) => isAnnotation(m) && m.attrs.id === markerRef) ? node.text : '';
137
+ }
138
+ return (node?.content ?? []).map((c) => annotatedText(c, markerRef)).join('');
139
+ }
140
+ const flatten = (node) => node?.type === 'text' ? node.text ?? '' : (node?.content ?? []).map(flatten).join('');
141
+ function annotationTypeOf(node, markerRef) {
142
+ if (node?.type === 'text') {
143
+ const found = (node.marks ?? []).find((m) => isAnnotation(m) && m.attrs.id === markerRef);
144
+ return found?.attrs?.annotationType ?? '';
145
+ }
146
+ for (const child of node?.content ?? []) {
147
+ const found = annotationTypeOf(child, markerRef);
148
+ if (found)
149
+ return found;
150
+ }
151
+ return '';
152
+ }
153
+ /**
154
+ * Describe where `markerRef` is anchored in `doc`. Returns null if absent.
155
+ *
156
+ * Tables are keyed by header row + the row's first-column value, so a comment
157
+ * on a field-reference row still resolves after rows are added or reordered
158
+ * above it — an index would not survive that.
159
+ */
160
+ export function locateAnnotation(doc, markerRef) {
161
+ for (const block of doc?.content ?? []) {
162
+ const text = annotatedText(block, markerRef);
163
+ if (!text)
164
+ continue;
165
+ const annotationType = annotationTypeOf(block, markerRef) || 'inlineComment';
166
+ if (block.type === TABLE) {
167
+ const header = (block.content?.[0]?.content ?? []).map(flatten);
168
+ for (const row of block.content ?? []) {
169
+ const cells = row.content ?? [];
170
+ const cell = cells.findIndex((c) => annotatedText(c, markerRef));
171
+ if (cell === -1)
172
+ continue;
173
+ return { kind: 'tableCell', header, rowKey: flatten(cells[0]), cell, text, annotationType };
174
+ }
175
+ continue; // marker counted inside the table but no cell owned it — treat as unlocatable
176
+ }
177
+ return { kind: 'block', blockType: block.type, blockText: flatten(block), text, annotationType };
178
+ }
179
+ return null;
180
+ }
181
+ /** Candidate nodes in `doc` that `locator` could describe. */
182
+ function findHosts(doc, locator) {
183
+ const blocks = doc?.content ?? [];
184
+ if (locator.kind === 'block') {
185
+ return blocks.filter((b) => b?.type === locator.blockType && flatten(b) === locator.blockText);
186
+ }
187
+ const hosts = [];
188
+ const wantHeader = JSON.stringify(locator.header);
189
+ for (const block of blocks) {
190
+ if (block?.type !== TABLE)
191
+ continue;
192
+ if (JSON.stringify((block.content?.[0]?.content ?? []).map(flatten)) !== wantHeader)
193
+ continue;
194
+ for (const row of block.content ?? []) {
195
+ const cells = row.content ?? [];
196
+ if (flatten(cells[0]) !== locator.rowKey)
197
+ continue;
198
+ if (cells[locator.cell])
199
+ hosts.push(cells[locator.cell]);
200
+ }
201
+ }
202
+ return hosts;
203
+ }
204
+ /** Rebuild `node` with `target` (matched by identity) swapped for `replacement`. */
205
+ function replaceNode(node, target, replacement) {
206
+ if (node === target)
207
+ return replacement;
208
+ if (!node || !Array.isArray(node.content))
209
+ return node;
210
+ return { ...node, content: node.content.map((c) => replaceNode(c, target, replacement)) };
211
+ }
212
+ /**
213
+ * Re-attach `markerRef` to `doc` at the position `locator` describes.
214
+ *
215
+ * 'ambiguous' means several equally-good hosts matched; the caller must decide,
216
+ * because guessing silently moves a reviewer's comment onto unrelated text.
217
+ * That is not hypothetical — anchor text "unique_id" occurs in ten text nodes
218
+ * of a real page whose true anchor was one specific table cell.
219
+ */
220
+ export function applyLocator(doc, locator, markerRef) {
221
+ const ann = { id: markerRef, annotationType: locator.annotationType, text: locator.text };
222
+ const structural = findHosts(doc, locator);
223
+ const hosts = structural.filter((h) => canMark(h, ann));
224
+ if (hosts.length === 0) {
225
+ // 'unmatchable' only when the text is genuinely still there but split across
226
+ // nodes. A surviving host proves nothing on its own: a renamed table field
227
+ // keeps its row while the anchored words are gone, and that is 'lost'.
228
+ const stillPresent = structural.some((h) => flatten(h).includes(ann.text));
229
+ return { doc, status: stillPresent ? 'unmatchable' : 'lost' };
230
+ }
231
+ if (hosts.length > 1)
232
+ return { doc, status: 'ambiguous' };
233
+ // Narrowing to one host is not enough: the anchor may repeat inside it, and
234
+ // applyOne would take the first occurrence and report success.
235
+ if (countCandidates(hosts[0], ann) > 1)
236
+ return { doc, status: 'ambiguous' };
237
+ const state = { applied: false };
238
+ const marked = applyOne(hosts[0], ann, state);
239
+ return state.applied ? { doc: replaceNode(doc, hosts[0], marked), status: 'exact' } : { doc, status: 'unmatchable' };
240
+ }
241
+ function canMark(host, ann) {
242
+ const state = { applied: false };
243
+ applyOne(host, ann, state);
244
+ return state.applied;
245
+ }
246
+ // ponytail: self-check, run with `npx tsx src/formatters/annotations.ts`
247
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
248
+ const { deepEqual, equal } = await import('node:assert');
249
+ const must = (loc) => {
250
+ if (!loc)
251
+ throw new Error('expected a locator, got null');
252
+ return loc;
253
+ };
254
+ const ann = (id) => ({ type: 'annotation', attrs: { id, annotationType: 'inlineComment' } });
255
+ const text = (t, marks) => ({ type: 'text', text: t, ...(marks ? { marks } : {}) });
256
+ const para = (...content) => ({ type: 'paragraph', content });
257
+ const doc = (...content) => ({ type: 'doc', version: 1, content });
258
+ // collect: single node, multi-node span, and none
259
+ deepEqual(collectAnnotations(doc(para(text('hi '), text('there', [ann('c1')])))), [
260
+ { id: 'c1', annotationType: 'inlineComment', text: 'there' },
261
+ ]);
262
+ deepEqual(collectAnnotations(doc(para(text('a', [ann('c1')]), text('b', [ann('c1'), { type: 'strong' }])))), [{ id: 'c1', annotationType: 'inlineComment', text: 'ab' }]);
263
+ deepEqual(collectAnnotations(doc(para(text('plain')))), []);
264
+ // reapply: text survived -> node splits into 3, mark on the middle slice
265
+ const anns = collectAnnotations(doc(para(text('fix '), text('this bug', [ann('c1')]), text(' now'))));
266
+ const fresh = doc(para(text('please fix this bug now, thanks')));
267
+ const r = reapplyAnnotations(fresh, anns);
268
+ equal(r.preserved.length, 1);
269
+ equal(r.dropped.length, 0);
270
+ deepEqual(r.doc.content[0].content, [
271
+ text('please fix '),
272
+ text('this bug', [ann('c1')]),
273
+ text(' now, thanks'),
274
+ ]);
275
+ // original left untouched
276
+ deepEqual(fresh.content[0].content, [text('please fix this bug now, thanks')]);
277
+ // reapply: anchors inside nested structures, and preserves existing marks
278
+ const nested = doc({
279
+ type: 'bulletList',
280
+ content: [{ type: 'listItem', content: [para(text('the bug', [{ type: 'strong' }]))] }],
281
+ });
282
+ const rn = reapplyAnnotations(nested, [{ id: 'c9', annotationType: 'inlineComment', text: 'the bug' }]);
283
+ equal(rn.preserved.length, 1);
284
+ deepEqual(rn.doc.content[0].content[0].content[0].content, [
285
+ text('the bug', [{ type: 'strong' }, ann('c9')]),
286
+ ]);
287
+ // reapply: reworded text is dropped, not force-fitted
288
+ const rd = reapplyAnnotations(doc(para(text('completely rewritten'))), anns);
289
+ equal(rd.preserved.length, 0);
290
+ deepEqual(rd.dropped.map((a) => a.id), ['c1']);
291
+ // an anchor occurring twice is refused, not guessed at — marking either one
292
+ // would move a reviewer's comment onto unrelated text and report success
293
+ const twice = [{ id: 'a', annotationType: 'inlineComment', text: 'dup' }];
294
+ const rtwice = reapplyAnnotations(doc(para(text('dup one')), para(text('dup two'))), twice);
295
+ deepEqual(rtwice.ambiguous.map((a) => a.id), ['a']);
296
+ equal(rtwice.preserved.length, 0);
297
+ deepEqual(rtwice.doc.content[0].content[0].marks, undefined); // nothing marked
298
+ // two comments on the SAME occurrence both attach — Confluence allows it, so
299
+ // an unrelated annotation must not hide the text from the next one
300
+ const same = [
301
+ { id: 'a', annotationType: 'inlineComment', text: 'shared' },
302
+ { id: 'b', annotationType: 'inlineComment', text: 'shared' },
303
+ ];
304
+ const rsame = reapplyAnnotations(doc(para(text('shared'))), same);
305
+ equal(rsame.preserved.length, 2);
306
+ equal(rsame.ambiguous.length, 0);
307
+ deepEqual(rsame.doc.content[0].content[0].marks, [ann('a'), ann('b')]);
308
+ // two comments sharing an anchor text over two occurrences are BOTH refused —
309
+ // each occurrence is a candidate for each id, so neither can be placed safely.
310
+ // ponytail: the old first-match behaviour paired them in document order and
311
+ // was right when comment order matched text order; refusing is the safe
312
+ // default. Upgrade path if it bites: pair by document order.
313
+ const rt = reapplyAnnotations(doc(para(text('dup ')), para(text('dup '))), [
314
+ { id: 'a', annotationType: 'inlineComment', text: 'dup ' },
315
+ { id: 'b', annotationType: 'inlineComment', text: 'dup ' },
316
+ ]);
317
+ equal(rt.ambiguous.length, 2);
318
+ equal(rt.preserved.length, 0);
319
+ // a repeat INSIDE one text node is two candidates, not one — counting nodes
320
+ // instead of occurrences let this through and marked the first silently
321
+ const twiceInOne = [{ id: 'r', annotationType: 'inlineComment', text: 'attributes' }];
322
+ const rIn = reapplyAnnotations(doc(para(text('set attributes before reading attributes'))), twiceInOne);
323
+ deepEqual(rIn.ambiguous.map((a) => a.id), ['r']);
324
+ deepEqual(rIn.doc.content[0].content[0].marks, undefined);
325
+ // same repeat, but reached through the structural locator — the primary path
326
+ const priorRepeat = doc(para(text('set '), text('attributes', [ann('r2')]), text(' before reading attributes')));
327
+ const locRepeat = must(locateAnnotation(priorRepeat, 'r2'));
328
+ const flatRepeat = doc(para(text('set attributes before reading attributes')));
329
+ equal(applyLocator(flatRepeat, locRepeat, 'r2').status, 'ambiguous');
330
+ // empty/whitespace anchor is never applied
331
+ equal(reapplyAnnotations(doc(para(text('x'))), [{ id: 'z', annotationType: 'inlineComment', text: ' ' }]).dropped.length, 1);
332
+ deepEqual(reapplyAnnotations(doc(), []), { doc: doc(), preserved: [], dropped: [], ambiguous: [] });
333
+ // ---- locateAnnotation / applyLocator ----
334
+ // Shapes and expectations mirror the real page this was built for: a
335
+ // field-reference table (header row 'JSON key'…) whose first column holds the
336
+ // field name, plus an h2 whose text IS the anchor.
337
+ const cell = (...content) => ({ type: 'tableCell', content });
338
+ const row = (...content) => ({ type: 'tableRow', content });
339
+ const p = (t, marks) => para(text(t, marks));
340
+ const fieldTable = (keyRowMarks) => ({
341
+ type: 'table',
342
+ content: [
343
+ row(cell(p('JSON key')), cell(p('Type')), cell(p('Notes'))),
344
+ row(cell(p('unique_id', keyRowMarks)), cell(p('string')), cell(p('the source_data key'))),
345
+ row(cell(p('attributes')), cell(p('object')), cell(p('your payload'))),
346
+ ],
347
+ });
348
+ // locate: table cell, keyed by header + first-column value, not by index
349
+ const prior = doc(fieldTable([ann('m1')]), { type: 'heading', attrs: { level: 2 }, content: [text('Field reference', [ann('m2')])] });
350
+ deepEqual(locateAnnotation(prior, 'm1'), {
351
+ kind: 'tableCell', header: ['JSON key', 'Type', 'Notes'], rowKey: 'unique_id', cell: 0,
352
+ text: 'unique_id', annotationType: 'inlineComment',
353
+ });
354
+ deepEqual(locateAnnotation(prior, 'm2'), {
355
+ kind: 'block', blockType: 'heading', blockText: 'Field reference',
356
+ text: 'Field reference', annotationType: 'inlineComment',
357
+ });
358
+ equal(locateAnnotation(prior, 'nope'), null);
359
+ // apply: lands on the field-name cell even though 'unique_id' also appears in
360
+ // another row's Notes column — the bug that motivated this resolver
361
+ const noisy = doc({
362
+ type: 'table',
363
+ content: [
364
+ row(cell(p('JSON key')), cell(p('Type')), cell(p('Notes'))),
365
+ row(cell(p('tenant_id')), cell(p('string')), cell(p('pairs with unique_id'))),
366
+ row(cell(p('unique_id')), cell(p('string')), cell(p('the source_data key'))),
367
+ ],
368
+ });
369
+ const applied = applyLocator(noisy, must(locateAnnotation(prior, 'm1')), 'm1');
370
+ equal(applied.status, 'exact');
371
+ deepEqual(applied.doc.content[0].content[2].content[0].content[0].content, [text('unique_id', [ann('m1')])]);
372
+ // the decoy row is untouched
373
+ deepEqual(applied.doc.content[0].content[1].content[2].content[0].content, [text('pairs with unique_id')]);
374
+ // apply: row inserted above the target shifts every index, rowKey still finds it
375
+ const shifted = doc({
376
+ type: 'table',
377
+ content: [
378
+ row(cell(p('JSON key')), cell(p('Type')), cell(p('Notes'))),
379
+ row(cell(p('event_name')), cell(p('string')), cell(p('added later'))),
380
+ row(cell(p('unique_id')), cell(p('string')), cell(p('the source_data key'))),
381
+ row(cell(p('attributes')), cell(p('object')), cell(p('your payload'))),
382
+ ],
383
+ });
384
+ equal(applyLocator(shifted, must(locateAnnotation(prior, 'm1')), 'm1').status, 'exact');
385
+ // ambiguous: two rows share the key, so there is no single right answer
386
+ const dupKey = doc({
387
+ type: 'table',
388
+ content: [
389
+ row(cell(p('JSON key')), cell(p('Type')), cell(p('Notes'))),
390
+ row(cell(p('unique_id')), cell(p('string')), cell(p('one'))),
391
+ row(cell(p('unique_id')), cell(p('string')), cell(p('two'))),
392
+ ],
393
+ });
394
+ const amb = applyLocator(dupKey, must(locateAnnotation(prior, 'm1')), 'm1');
395
+ equal(amb.status, 'ambiguous');
396
+ equal(JSON.stringify(amb.doc), JSON.stringify(dupKey)); // must not mark anything
397
+ // lost: the row is gone, and separately, the table header changed
398
+ const rowGone = doc({
399
+ type: 'table',
400
+ content: [row(cell(p('JSON key')), cell(p('Type')), cell(p('Notes'))), row(cell(p('attributes')), cell(p('object')), cell(p('x')))],
401
+ });
402
+ equal(applyLocator(rowGone, must(locateAnnotation(prior, 'm1')), 'm1').status, 'lost');
403
+ equal(applyLocator(doc({
404
+ type: 'table',
405
+ content: [row(cell(p('Field')), cell(p('Type')), cell(p('Notes'))), row(cell(p('unique_id')), cell(p('string')), cell(p('x')))],
406
+ }), must(locateAnnotation(prior, 'm1')), 'm1').status, 'lost');
407
+ // lost: heading text reworded
408
+ equal(applyLocator(doc({ type: 'heading', attrs: { level: 2 }, content: [text('Fields')] }), must(locateAnnotation(prior, 'm2')), 'm2').status, 'lost');
409
+ // exact: heading unchanged
410
+ equal(applyLocator(doc({ type: 'heading', attrs: { level: 2 }, content: [text('Field reference')] }), must(locateAnnotation(prior, 'm2')), 'm2').status, 'exact');
411
+ // unmatchable, NOT lost: the block survives but the anchor spans two text
412
+ // nodes, so it cannot be marked as one run — the reason must say so
413
+ const spanning = doc(para(text('fix '), text('this', [ann('m3'), { type: 'strong' }]), text(' bug')));
414
+ const spanLoc = must(locateAnnotation(spanning, 'm3'));
415
+ equal(spanLoc.text, 'this');
416
+ const splitAnchor = doc(para(text('fix '), text('th'), text('is bug')));
417
+ equal(applyLocator(splitAnchor, { ...spanLoc, text: 'this bug' }, 'm3').status, 'unmatchable');
418
+ // and genuinely-gone text still reports lost
419
+ equal(applyLocator(doc(para(text('unrelated'))), { ...spanLoc, text: 'this bug' }, 'm3').status, 'lost');
420
+ // lost, NOT unmatchable: the host row survives but the anchored field was
421
+ // renamed away. A surviving host must not be read as surviving text.
422
+ const priorRenamed = doc({
423
+ type: 'table',
424
+ content: [
425
+ row(cell(p('JSON key')), cell(p('Type')), cell(p('Notes'))),
426
+ row(cell(p('event_name')), cell(p('string')), cell(para(text('must match an '), text('alert_config', [ann('m4')])))),
427
+ ],
428
+ });
429
+ const renamedLoc = must(locateAnnotation(priorRenamed, 'm4'));
430
+ equal(renamedLoc.kind === 'tableCell' && renamedLoc.rowKey, 'event_name');
431
+ const renamedNow = doc({
432
+ type: 'table',
433
+ content: [
434
+ row(cell(p('JSON key')), cell(p('Type')), cell(p('Notes'))),
435
+ row(cell(p('event_name')), cell(p('string')), cell(p('must match a configured alert or notification'))),
436
+ ],
437
+ });
438
+ equal(applyLocator(renamedNow, renamedLoc, 'm4').status, 'lost');
439
+ console.log('annotations: ok');
440
+ }
441
+ //# sourceMappingURL=annotations.js.map