@erdemtuna/doc-review 0.9.0 → 0.10.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/README.md +52 -227
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +2255 -0
- package/lib/chrome-session.js +76 -0
- package/lib/cli.js +255 -0
- package/lib/click-target.js +28 -0
- package/lib/comment-anchor.js +23 -0
- package/lib/comment-target.js +164 -0
- package/lib/comparison-view.js +380 -0
- package/lib/contracts/feedback.js +1 -0
- package/lib/contracts/frame.js +7 -0
- package/lib/contracts/history.js +1 -0
- package/lib/contracts/index.js +2 -0
- package/lib/contracts/page.js +23 -0
- package/lib/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +69 -0
- package/lib/feedback-controller.js +106 -0
- package/lib/frame-channel.js +42 -0
- package/lib/frame-controller.js +313 -0
- package/lib/frame-host.js +136 -0
- package/lib/frame-policy.js +50 -0
- package/lib/history-client.js +112 -0
- package/lib/history-coordinator.js +210 -0
- package/lib/history-policy.js +43 -0
- package/lib/history-server.js +464 -0
- package/lib/html-transform.js +52 -0
- package/lib/icons.js +249 -0
- package/{src → lib}/markdown.js +25 -26
- package/lib/paths.js +83 -0
- package/lib/poll-transport.js +220 -0
- package/lib/positioning.js +97 -0
- package/lib/review-controller.js +93 -0
- package/lib/review-mode.js +66 -0
- package/lib/revision-diff.js +482 -0
- package/lib/revision-schema.js +235 -0
- package/lib/revision-store.js +190 -0
- package/lib/save-controller.js +300 -0
- package/lib/sdk.js +2629 -0
- package/lib/semantic-snapshot.js +268 -0
- package/lib/serialize.js +32 -0
- package/lib/server-entry.js +26 -0
- package/lib/server-lock.js +109 -0
- package/lib/server.js +1501 -0
- package/lib/setup-guidance.js +119 -0
- package/{src → lib}/setup.js +44 -55
- package/lib/state.js +733 -0
- package/lib/view-identity.js +112 -0
- package/package.json +24 -14
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2747
- package/src/chrome-session.js +0 -85
- package/src/cli.js +0 -271
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -164
- package/src/comparison-view.js +0 -337
- package/src/document-execution.js +0 -103
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/execution-client.js +0 -63
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -54
- package/src/history-client.js +0 -104
- package/src/history-coordinator.js +0 -186
- package/src/history-policy.js +0 -43
- package/src/history-server.js +0 -463
- package/src/html-transform.js +0 -62
- package/src/icons.js +0 -251
- package/src/paths.js +0 -91
- package/src/poll-transport.js +0 -222
- package/src/positioning.js +0 -107
- package/src/review-mode.js +0 -59
- package/src/revision-diff.js +0 -440
- package/src/revision-schema.js +0 -219
- package/src/revision-store.js +0 -177
- package/src/sdk.js +0 -2607
- package/src/semantic-snapshot.js +0 -230
- package/src/serialize.js +0 -32
- package/src/server-entry.js +0 -26
- package/src/server-lock.js +0 -101
- package/src/server.js +0 -1466
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -732
- package/src/view-identity.js +0 -99
- /package/{src → lib}/SKILL.md +0 -0
- /package/{src → lib}/chrome.css +0 -0
- /package/{src → lib}/chrome.html +0 -0
- /package/{src → lib}/document-trust.js +0 -0
package/README.md
CHANGED
|
@@ -1,260 +1,85 @@
|
|
|
1
1
|
# Doc Review
|
|
2
2
|
|
|
3
|
-
Review
|
|
3
|
+
**Review your coding agent's work in the browser, not in a wall of chat.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Open an HTML file, a Markdown document, or a localhost page. Point to what needs
|
|
6
|
+
changing, edit the small things yourself, and send your feedback to the agent
|
|
7
|
+
in one batch.
|
|
6
8
|
|
|
7
|
-
https://
|
|
9
|
+

|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
*Feedback stays beside the work. Your agent gets the comments, edits, and overall note together.*
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
## Get started
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
> In the third paragraph, change X to Y. Cut the third card because it repeats the first one. Also rewrite the CTA.
|
|
16
|
-
|
|
17
|
-
Then the agent changes the file and you have to check whether it understood every instruction. This gets even harder when you’re reviewing a long plan, Markdown document, landing page, or multi-page website.
|
|
18
|
-
|
|
19
|
-
## How to install /doc-review
|
|
20
|
-
|
|
21
|
-
The easiest way to install the skill is to paste this into ChatGPT, Claude Code, Codex, or your favorite coding agent:
|
|
22
|
-
|
|
23
|
-
```text
|
|
24
|
-
Install the /doc-review skill globally from https://github.com/erdemtuna/doc-review
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
You can also install it with `npx`:
|
|
15
|
+
Requires **Node.js 24.21.0 or newer** and a coding agent that can run shell commands.
|
|
16
|
+
Install the skill once:
|
|
28
17
|
|
|
29
18
|
```sh
|
|
30
19
|
npx -y @erdemtuna/doc-review setup --global
|
|
31
20
|
```
|
|
32
21
|
|
|
33
|
-
|
|
34
|
-
`~/.doc-review` state directory. It does not install a `human-review` alias or
|
|
35
|
-
migrate upstream state and skill files; remove those old files manually if you
|
|
36
|
-
no longer need them.
|
|
37
|
-
|
|
38
|
-
## How to use /doc-review
|
|
39
|
-
|
|
40
|
-
Review is opt-in: explicitly invoke `/doc-review` or ask to open an interactive
|
|
41
|
-
browser review. Writing, updating, or asking for a general review of content does
|
|
42
|
-
not automatically open the browser or start polling. An explicitly started review
|
|
43
|
-
continues until you end it or switch tasks.
|
|
44
|
-
|
|
45
|
-
After updating installed skill instructions, start a fresh agent session or reload
|
|
46
|
-
skills (in Copilot CLI, `/skills reload`). Setup copies the executing package's
|
|
47
|
-
skill template; rerunning setup from an older package can restore older behavior.
|
|
48
|
-
Setup updates its own `AGENTS.md` guidance inside `<!-- BEGIN doc-review -->` and
|
|
49
|
-
`<!-- END doc-review -->` markers. Exact, known older generated blocks are migrated.
|
|
50
|
-
Custom guidance is preserved with a migration message rather than overwritten;
|
|
51
|
-
update it yourself if it requests automatic review. Invalid or duplicate markers
|
|
52
|
-
must be corrected before setup can proceed.
|
|
53
|
-
|
|
54
|
-

|
|
55
|
-
|
|
56
|
-
Open an HTML or Markdown file:
|
|
22
|
+
Start a fresh agent session, then ask it to open a review:
|
|
57
23
|
|
|
58
24
|
```text
|
|
59
|
-
/doc-review
|
|
25
|
+
/doc-review path/to/landing-page.html
|
|
60
26
|
```
|
|
61
27
|
|
|
62
|
-
|
|
28
|
+
The same command works with Markdown or a running local app:
|
|
63
29
|
|
|
64
30
|
```text
|
|
65
|
-
/doc-review
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
Doc Review opens in **Review**, with **View** selected, so you can read, use page controls, and comment. Switch to **Edit** to change content directly. Plain HTML saves edits to the file; scripted HTML, Markdown, and localhost pages send edits to your agent. Commenting stays available in both modes.
|
|
69
|
-
|
|
70
|
-
Select text, or hover or focus an element, then use the nearby comment icon. `Ctrl+Alt+M` (`Cmd+Option+M` on macOS) opens a comment for the current selection. Press Enter to submit or Shift+Enter for a new line. On desktop the composer stays beside its target, or pins to the effective top or bottom scrolling edge when that target leaves view. **Back to selection** reveals the target without changing the draft. The full-width sheet is used only at the narrow responsive breakpoint.
|
|
71
|
-
|
|
72
|
-
**Comments** is the single toolbar entry point for the closed-by-default review drawer. Its feedback inventory scrolls independently while the overall note and **Send to agent** controls remain fixed. Submitting a comment creates its normal highlight and increments the count, but keeps the card closed until you activate its mark or choose **Jump to**. Focus returns to the exact element or selection you reviewed.
|
|
73
|
-
|
|
74
|
-
Aligned cards show **Edit**, **Close**, and **More**; drawer cards show **Jump to**, **Edit**, and **More**. Delete lives only in **More** and requires an inline confirmation. Quote hints preserve both the beginning and ending of long selections. Editing has explicit **Save** and **Cancel** controls: Enter saves, Shift+Enter adds a line, Escape cancels, and moving focus never autosaves. A draft follows its comment between aligned and drawer cards and keeps its caret through target movement.
|
|
75
|
-
|
|
76
|
-
### Files and page interactions
|
|
77
|
-
|
|
78
|
-
Self-contained HTML runs its inline scripts and event handlers automatically.
|
|
79
|
-
There is no approval switch or renewed approval after an agent updates the file.
|
|
80
|
-
Plain HTML, including ordinary JSON data blocks and escaped code examples, keeps
|
|
81
|
-
direct autosave and Revert. Scripted HTML is **feedback-only**: your edits are sent
|
|
82
|
-
to the agent to apply to the original source, rather than writing script-generated
|
|
83
|
-
DOM into the file. The renderer re-evaluates that distinction when the source changes.
|
|
84
|
-
Markdown and localhost also remain feedback-only.
|
|
85
|
-
|
|
86
|
-
If a scripted page is broken, **More > Reload without scripts** provides a
|
|
87
|
-
temporary recovery mode. **Use page interactions** restores automatic behavior.
|
|
88
|
-
That preference belongs to this page in this review session, not to a saved
|
|
89
|
-
approval record. Disabling scripts does not make a scripted file's preview
|
|
90
|
-
edits writable. A changed preference takes effect when the frame is replaced;
|
|
91
|
-
reload handling preserves comment drafts and reports genuine source conflicts.
|
|
92
|
-
|
|
93
|
-
The supported interactive-file mode is for self-contained documents. Separate
|
|
94
|
-
script files, application imports, workers, and embedded applications are not
|
|
95
|
-
made compatible by this change. Use the existing localhost review route for
|
|
96
|
-
application workflows. Dependency limitations appear in contextual details,
|
|
97
|
-
not as a permission task or text inserted into the authored document.
|
|
98
|
-
|
|
99
|
-
Files retain an opaque iframe origin. The parent owns API authentication and
|
|
100
|
-
checks the frame identity for messages and writes. Authored code shares the
|
|
101
|
-
document with the SDK, so frame correlation is not proof of human authorship.
|
|
102
|
-
Relative assets stay scoped to the reviewed file. Localhost reviews retain their
|
|
103
|
-
existing nonopaque artifact origin, popup, and download behavior; they are fetched
|
|
104
|
-
and rewritten by the review server, not guaranteed to reproduce the application's
|
|
105
|
-
original cookies, storage, or origin-sensitive requests.
|
|
106
|
-
|
|
107
|
-
**Compatibility:** the old per-version `/trust` API is retired and returns 410.
|
|
108
|
-
Existing decision files are left unused. The CLI does not reuse background
|
|
109
|
-
servers with the previous protocol. Writable HTML save and revert requests must
|
|
110
|
-
identify their current served frame and source hash so delayed edits cannot
|
|
111
|
-
overwrite a newer file.
|
|
112
|
-
|
|
113
|
-
Agent polling returns an immutable `batch_id`. After applying the batch, the
|
|
114
|
-
agent acknowledges that exact receipt and keeps waiting:
|
|
115
|
-
|
|
116
|
-
```sh
|
|
117
|
-
npx -y @erdemtuna/doc-review poll path/to/file.html --ack b_0123456789abcdef --timeout 600
|
|
31
|
+
/doc-review path/to/plan.md
|
|
32
|
+
/doc-review http://localhost:3000
|
|
118
33
|
```
|
|
119
34
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
### Review and Changes
|
|
124
|
-
|
|
125
|
-
**Review** keeps the document interactive. **Changes** shows a selected review
|
|
126
|
-
round, with round selection and comparison navigation instead of live-page controls.
|
|
127
|
-
The default comparison is the content captured when feedback was sent against
|
|
128
|
-
the result captured after the agent acknowledged that exact batch. Previous
|
|
129
|
-
rounds retain their own fixed endpoints rather than changing on every reload.
|
|
130
|
-
|
|
131
|
-
Content comparisons use a continuous aligned before/after reading surface with
|
|
132
|
-
expandable unchanged context. Source comparisons use line-numbered source hunks.
|
|
133
|
-
At narrow widths, a unified view retains each change's before/after attribution.
|
|
134
|
-
Content comparisons cover text, structure, formatting, links, and image
|
|
135
|
-
references. File-backed reviews also retain source for a source comparison.
|
|
136
|
-
Deleted passages remain readable even when there is no current element to jump
|
|
137
|
-
to, and uncertain matches do not pretend to identify an exact current target.
|
|
138
|
-
These are changes observed during a round, not proof of agent authorship or that
|
|
139
|
-
every comment was resolved.
|
|
140
|
-
|
|
141
|
-
History works in the normal browser for HTML, Markdown, and localhost pages.
|
|
142
|
-
It does not use screenshots, a browser extension, or automatic Git commits.
|
|
143
|
-
Live history captures the reviewed page's available content, not every possible
|
|
144
|
-
application state. Changes to image bytes at an unchanged URL, canvas pixels,
|
|
145
|
-
external stylesheet appearance, and inaccessible embedded content are outside
|
|
146
|
-
the comparison guarantee.
|
|
147
|
-
|
|
148
|
-
Send waits for edit persistence and writable HTML saves, then makes a short
|
|
149
|
-
best-effort baseline capture. Missing comparison data does not require a second
|
|
150
|
-
decision or prevent feedback from being sent. Available snapshots are retained;
|
|
151
|
-
inactive pages can have incomplete Content coverage. A last-visited live page is
|
|
152
|
-
never described as a fresh Send-time snapshot. Actual save or delivery failures
|
|
153
|
-
are reported without discarding your feedback.
|
|
154
|
-
|
|
155
|
-
When an active tab has reliable authored tab/panel identifiers, Content capture
|
|
156
|
-
records that visible view. A result on a different tab remains pending and asks
|
|
157
|
-
you to return to the original tab. Returning retries capture without clicking
|
|
158
|
-
through other tabs automatically. Unknown views and older snapshots are labelled
|
|
159
|
-
as visible-content comparisons whose matching view is unverified. This does not
|
|
160
|
-
capture all hidden panels or every application state. File Source comparison
|
|
161
|
-
still covers the whole file independently.
|
|
162
|
-
|
|
163
|
-
After acknowledgement, file-source results are captured independently of the
|
|
164
|
-
browser. Content results are captured when the appropriate reviewed page is
|
|
165
|
-
ready. These observations can have different timestamps; neither timestamp
|
|
166
|
-
claims that the document was captured at the instant of acknowledgement.
|
|
167
|
-
Background capture does not disable document interaction and retries transient
|
|
168
|
-
failures a bounded number of times. An unchanged file does not need a new reload
|
|
169
|
-
solely to capture an acknowledged result. Available Source remains readable
|
|
170
|
-
while Content is pending. Contextual retry or return-to-view actions handle
|
|
171
|
-
remaining failures; a continuously changing page may still have no stable Content
|
|
172
|
-
snapshot. Permanent finalization is optional in capture details, not a prerequisite
|
|
173
|
-
for reading the comparison. Failed or limited captures are not reported as zero
|
|
174
|
-
changes, and handled feedback remains archived even when history is unavailable.
|
|
175
|
-
|
|
176
|
-
Snapshots stay in the local Doc Review state directory. The latest five
|
|
177
|
-
completed rounds are retained automatically, while active rounds, pending
|
|
178
|
-
captures, and unsent feedback protect their referenced revisions. History starts
|
|
179
|
-
with this capability; earlier documents cannot be reconstructed from old
|
|
180
|
-
feedback receipts. Review snapshots can contain document content, so treat the
|
|
181
|
-
state directory as private.
|
|
182
|
-
Unreferenced snapshot files are collected at startup and during periodic
|
|
183
|
-
maintenance, with a one-hour grace period protecting interrupted publications.
|
|
184
|
-
Capture and comparison limits fail explicitly rather than publishing truncated
|
|
185
|
-
content as a complete comparison.
|
|
186
|
-
|
|
187
|
-
Default capture limits are 8 MiB of file source and 4 MiB of normalized content,
|
|
188
|
-
with at most 2,000 semantic blocks and 1,000,000 text characters. A single block
|
|
189
|
-
is limited to 100,000 characters. Comparisons have independent size, token, and
|
|
190
|
-
edit-work limits, including a 1,000,000-character budget and a 250 ms processing
|
|
191
|
-
budget. A stored snapshot can therefore exceed comparison limits; the UI reports
|
|
192
|
-
that limitation rather than claiming there were no changes.
|
|
193
|
-
|
|
194
|
-
### Feedback reliability and limits
|
|
195
|
-
|
|
196
|
-
`poll` without `--timeout` waits for at most 12 hours. Explicit timeouts include
|
|
197
|
-
server discovery, reconnection, and retry backoff, not just time spent connected.
|
|
198
|
-
Recoverable connection drops are retried; invalid responses, authorization errors,
|
|
199
|
-
and incompatible servers fail visibly. A timeout does not discard feedback: use
|
|
200
|
-
`status` to check it, then start another poll if the review is still wanted.
|
|
201
|
-
|
|
202
|
-
Each edit text or HTML field is limited to 200,000 Unicode code points. Oversized
|
|
203
|
-
edits carry `truncated: true` and `truncated_fields` naming incomplete fields.
|
|
204
|
-
The agent must not use partial text or HTML as a complete replacement or invent
|
|
205
|
-
the rest. It must obtain the full edit from an authoritative source or ask you
|
|
206
|
-
for it before acknowledging the batch.
|
|
207
|
-
|
|
208
|
-
External writes to an HTML or Markdown source refresh its rendered baseline without
|
|
209
|
-
clearing unsent feedback. That preserves your edits; it does not automatically
|
|
210
|
-
resolve conflicts with the changed source. HTML autosaves keep their existing
|
|
211
|
-
behavior.
|
|
35
|
+
Reviews start only when you request them. Your agent uses the installed skill
|
|
36
|
+
to open the page, wait for feedback, and apply the changes.
|
|
212
37
|
|
|
213
|
-
|
|
38
|
+
## From feedback to the next version
|
|
214
39
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
40
|
+
1. **Open and explore.** Reviews start in **View**, so you can read and use page
|
|
41
|
+
controls without accidentally editing.
|
|
42
|
+
2. **Point out what matters.** Select text or choose an element to leave a
|
|
43
|
+
comment. Switch to **Edit** for direct changes to wording, formatting, images,
|
|
44
|
+
or layout. Commenting works in either mode.
|
|
45
|
+
3. **Send one batch.** Open **Comments**, add an overall note if needed, and
|
|
46
|
+
choose **Send to agent**. No need to describe where every sentence lives.
|
|
47
|
+
4. **Check the result.** Use **Changes** to compare a review round's captured
|
|
48
|
+
before and after content, then continue reviewing. Comparisons show observed
|
|
49
|
+
changes, not a guarantee that every request was resolved.
|
|
220
50
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
Reload skills or start a fresh agent session afterward. A newer CLI paired with
|
|
224
|
-
older instructions is not a complete upgrade.
|
|
51
|
+
You can review a plan, refine a landing page, or walk through a local app without
|
|
52
|
+
moving your feedback into a separate document.
|
|
225
53
|
|
|
226
|
-
## What
|
|
54
|
+
## What happens to your edits?
|
|
227
55
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
- **Paste images** from your clipboard — file reviews save them beside the document; localhost reviews stage them for the agent to place in the app source.
|
|
234
|
-
- **Select a phrase and leave a comment** from the contextual icon, or press Ctrl+Alt+M / Cmd+Option+M.
|
|
235
|
-
- **Comment on an image, chart, control, or section** from its hover or keyboard-focus affordance without taking over its normal click.
|
|
236
|
-
- **Remove elements** without explaining the deletion in chat.
|
|
237
|
-
- **Command-click links** to review multiple pages without losing your feedback.
|
|
238
|
-
- **Send every edit and comment at once** instead of writing a long chat message.
|
|
56
|
+
| What you open | Where edits go |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| Plain HTML | Saved directly to the file, with Revert available |
|
|
59
|
+
| Scripted HTML or Markdown | Sent to the agent to apply to the original source |
|
|
60
|
+
| Localhost page | Sent to the agent to update the app's source |
|
|
239
61
|
|
|
240
|
-
|
|
62
|
+
Self contained HTML can run inline scripts. For an app with separate script
|
|
63
|
+
dependencies, use its localhost URL instead.
|
|
241
64
|
|
|
242
|
-
|
|
65
|
+
Doc Review runs locally and needs no Doc Review account, hosted backend, or API
|
|
66
|
+
key. The page you review and the coding agent you use may still contact external
|
|
67
|
+
services.
|
|
243
68
|
|
|
244
|
-
|
|
245
|
-
- [`server.js`](src/server.js) runs the local review session.
|
|
246
|
-
- [`sdk.js`](src/sdk.js) handles editing, comments, highlights, and feedback.
|
|
247
|
-
- [`chrome-client.js`](src/chrome-client.js) contains the visual review interface.
|
|
248
|
-
- [`markdown.js`](src/markdown.js) renders Markdown files for review.
|
|
249
|
-
- [`SKILL.md`](src/SKILL.md) teaches Claude Code, Codex, and other agents how to use Doc Review.
|
|
69
|
+
## Learn more
|
|
250
70
|
|
|
251
|
-
|
|
252
|
-
|
|
71
|
+
[Usage guide](https://github.com/erdemtuna/doc-review/blob/main/docs/usage.md):
|
|
72
|
+
setup options, comments, comparisons, limitations, and upgrades.
|
|
253
73
|
|
|
254
|
-
|
|
74
|
+
[Development](https://github.com/erdemtuna/doc-review/blob/main/docs/development.md):
|
|
75
|
+
build, test, architecture, and package checks.
|
|
255
76
|
|
|
256
|
-
|
|
77
|
+
[Releasing](https://github.com/erdemtuna/doc-review/blob/main/RELEASING.md):
|
|
78
|
+
the maintainers' release process.
|
|
257
79
|
|
|
258
|
-
##
|
|
80
|
+
## Credits and license
|
|
259
81
|
|
|
260
|
-
|
|
82
|
+
Doc Review is an independent fork of Peter Yang's
|
|
83
|
+
[Human Review](https://github.com/petergyang/human-review), continuing from
|
|
84
|
+
upstream v0.6.1. Licensed under
|
|
85
|
+
[MIT](https://github.com/erdemtuna/doc-review/blob/main/LICENSE).
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote-with-context anchoring, in the spirit of the W3C TextQuoteSelector.
|
|
3
|
+
*
|
|
4
|
+
* These functions are pure string maths so they can be unit-tested in Node and
|
|
5
|
+
* shipped verbatim to the browser, where the DOM half of anchoring lives.
|
|
6
|
+
*/
|
|
7
|
+
export const CONTEXT_PAD = 32;
|
|
8
|
+
/** Capture `quote` plus surrounding context so it can be re-found after edits. */
|
|
9
|
+
export function buildContext(text, start, end, pad = CONTEXT_PAD) {
|
|
10
|
+
return {
|
|
11
|
+
prefix: text.slice(Math.max(0, start - pad), start),
|
|
12
|
+
quote: text.slice(start, end),
|
|
13
|
+
suffix: text.slice(end, Math.min(text.length, end + pad)),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function commonSuffixLength(a, b) {
|
|
17
|
+
let n = 0;
|
|
18
|
+
while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n])
|
|
19
|
+
n += 1;
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
function commonPrefixLength(a, b) {
|
|
23
|
+
let n = 0;
|
|
24
|
+
while (n < a.length && n < b.length && a[n] === b[n])
|
|
25
|
+
n += 1;
|
|
26
|
+
return n;
|
|
27
|
+
}
|
|
28
|
+
function occurrences(text, quote) {
|
|
29
|
+
const found = [];
|
|
30
|
+
let at = text.indexOf(quote);
|
|
31
|
+
while (at !== -1) {
|
|
32
|
+
found.push(at);
|
|
33
|
+
at = text.indexOf(quote, at + 1);
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
function bestHit(text, hits, quote, prefix, suffix) {
|
|
38
|
+
let best = hits[0];
|
|
39
|
+
let bestScore = -1;
|
|
40
|
+
for (const at of hits) {
|
|
41
|
+
const before = text.slice(Math.max(0, at - prefix.length), at);
|
|
42
|
+
const after = text.slice(at + quote.length, at + quote.length + suffix.length);
|
|
43
|
+
const score = commonSuffixLength(prefix, before) + commonPrefixLength(suffix, after);
|
|
44
|
+
if (score > bestScore) {
|
|
45
|
+
bestScore = score;
|
|
46
|
+
best = at;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { at: best, score: bestScore };
|
|
50
|
+
}
|
|
51
|
+
const collapse = (s) => String(s || "").replace(/\s+/g, " ");
|
|
52
|
+
/** Collapse whitespace runs, keeping a map from each kept char to its original index. */
|
|
53
|
+
function collapseWithMap(text) {
|
|
54
|
+
let flat = "";
|
|
55
|
+
const map = [];
|
|
56
|
+
let pendingWs = -1;
|
|
57
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
58
|
+
if (/\s/.test(text[i])) {
|
|
59
|
+
if (pendingWs === -1)
|
|
60
|
+
pendingWs = i;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (pendingWs !== -1 && flat) {
|
|
64
|
+
flat += " ";
|
|
65
|
+
map.push(pendingWs);
|
|
66
|
+
}
|
|
67
|
+
pendingWs = -1;
|
|
68
|
+
flat += text[i];
|
|
69
|
+
map.push(i);
|
|
70
|
+
}
|
|
71
|
+
return { flat, map };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Locate `ctx.quote` in `text`, using prefix/suffix to disambiguate repeats.
|
|
75
|
+
* Returns `{ start, end, exact }` or null when the quote is gone entirely.
|
|
76
|
+
*/
|
|
77
|
+
export function findQuote(text, ctx) {
|
|
78
|
+
const quote = ctx && ctx.quote;
|
|
79
|
+
if (!quote)
|
|
80
|
+
return null;
|
|
81
|
+
const hits = occurrences(text, quote);
|
|
82
|
+
if (hits.length === 1) {
|
|
83
|
+
return { start: hits[0], end: hits[0] + quote.length, exact: true };
|
|
84
|
+
}
|
|
85
|
+
if (hits.length > 1) {
|
|
86
|
+
const { at, score } = bestHit(text, hits, quote, ctx.prefix || "", ctx.suffix || "");
|
|
87
|
+
return { start: at, end: at + quote.length, exact: score > 0 };
|
|
88
|
+
}
|
|
89
|
+
// Reformatting (a prettier run, an agent rewrite) reflows whitespace without
|
|
90
|
+
// changing any words. Match again on whitespace-collapsed text and map the
|
|
91
|
+
// hit back to real offsets, so those comments survive instead of orphaning.
|
|
92
|
+
const { flat, map } = collapseWithMap(text);
|
|
93
|
+
const flatQuote = collapse(quote).trim();
|
|
94
|
+
if (!flatQuote || !map.length)
|
|
95
|
+
return null;
|
|
96
|
+
const flatHits = occurrences(flat, flatQuote);
|
|
97
|
+
if (!flatHits.length)
|
|
98
|
+
return null;
|
|
99
|
+
const { at } = bestHit(flat, flatHits, flatQuote, collapse(ctx.prefix || ""), collapse(ctx.suffix || ""));
|
|
100
|
+
return { start: map[at], end: map[at + flatQuote.length - 1] + 1, exact: false };
|
|
101
|
+
}
|
|
102
|
+
/** Collapse runs of whitespace for display in a comment card. */
|
|
103
|
+
export function tidy(text, limit = 0) {
|
|
104
|
+
const flat = String(text == null ? "" : text)
|
|
105
|
+
.replace(/\s+/g, " ")
|
|
106
|
+
.trim();
|
|
107
|
+
if (!limit || flat.length <= limit)
|
|
108
|
+
return flat;
|
|
109
|
+
return `${flat.slice(0, limit - 1).trimEnd()}…`;
|
|
110
|
+
}
|
|
111
|
+
function graphemes(text) {
|
|
112
|
+
if (typeof Intl?.Segmenter === "function") {
|
|
113
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
114
|
+
return [...segmenter.segment(text)].map(({ segment }) => segment);
|
|
115
|
+
}
|
|
116
|
+
// Array.from is deterministic and, unlike string slicing, never splits a
|
|
117
|
+
// surrogate pair. Older engines may split a multi-code-point grapheme.
|
|
118
|
+
return Array.from(text);
|
|
119
|
+
}
|
|
120
|
+
function nearbyHeadBoundary(parts, ideal) {
|
|
121
|
+
for (let index = ideal; index >= Math.max(1, ideal - 8); index -= 1) {
|
|
122
|
+
if (/^\s$/u.test(parts[index - 1]))
|
|
123
|
+
return index - 1;
|
|
124
|
+
}
|
|
125
|
+
return ideal;
|
|
126
|
+
}
|
|
127
|
+
function nearbyTailBoundary(parts, ideal) {
|
|
128
|
+
for (let index = ideal; index < Math.min(parts.length - 1, ideal + 8); index += 1) {
|
|
129
|
+
if (/^\s$/u.test(parts[index]))
|
|
130
|
+
return index + 1;
|
|
131
|
+
}
|
|
132
|
+
return ideal;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Collapse whitespace and preserve both ends of a long quote. `limit` counts
|
|
136
|
+
* grapheme clusters, including the ellipsis.
|
|
137
|
+
*/
|
|
138
|
+
export function tidyMiddle(text, limit = 0) {
|
|
139
|
+
const flat = String(text == null ? "" : text)
|
|
140
|
+
.replace(/\s+/g, " ")
|
|
141
|
+
.trim();
|
|
142
|
+
if (!limit)
|
|
143
|
+
return flat;
|
|
144
|
+
const parts = graphemes(flat);
|
|
145
|
+
if (parts.length <= limit)
|
|
146
|
+
return flat;
|
|
147
|
+
if (limit <= 1)
|
|
148
|
+
return "…";
|
|
149
|
+
const available = limit - 1;
|
|
150
|
+
const idealHead = Math.ceil(available * 0.6);
|
|
151
|
+
const idealTailStart = parts.length - (available - idealHead);
|
|
152
|
+
const headEnd = nearbyHeadBoundary(parts, idealHead);
|
|
153
|
+
const tailStart = nearbyTailBoundary(parts, idealTailStart);
|
|
154
|
+
return `${parts.slice(0, headEnd).join("").trimEnd()}…${parts.slice(tailStart).join("").trimStart()}`;
|
|
155
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
const RETRY_DELAYS_MS = [10, 20, 40, 80, 160];
|
|
4
|
+
const TRANSIENT_RENAME_ERRORS = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
5
|
+
const waitState = new Int32Array(new SharedArrayBuffer(4));
|
|
6
|
+
// Keep Store transactions synchronous; rename backoff blocks for at most 310ms.
|
|
7
|
+
const sleepSync = (ms) => Atomics.wait(waitState, 0, 0, ms);
|
|
8
|
+
export function createAtomicWriter({ fileSystem = fs, sleep = sleepSync, report = console.error } = {}) {
|
|
9
|
+
return function atomicWrite(file, data) {
|
|
10
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.doc-review.tmp`;
|
|
11
|
+
let fd;
|
|
12
|
+
let created = false;
|
|
13
|
+
try {
|
|
14
|
+
fd = fileSystem.openSync(tmp, "wx");
|
|
15
|
+
created = true;
|
|
16
|
+
fileSystem.writeFileSync(fd, data);
|
|
17
|
+
fileSystem.closeSync(fd);
|
|
18
|
+
fd = undefined;
|
|
19
|
+
for (let attempt = 0;; attempt += 1) {
|
|
20
|
+
try {
|
|
21
|
+
fileSystem.renameSync(tmp, file);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (!TRANSIENT_RENAME_ERRORS.has(err.code) || attempt === RETRY_DELAYS_MS.length)
|
|
26
|
+
throw err;
|
|
27
|
+
sleep(RETRY_DELAYS_MS[attempt]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (fd !== undefined) {
|
|
33
|
+
try {
|
|
34
|
+
fileSystem.closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
catch (cleanupError) {
|
|
37
|
+
report(`Could not close atomic-write temporary file ${tmp}: ${cleanupError.message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (created) {
|
|
41
|
+
try {
|
|
42
|
+
fileSystem.unlinkSync(tmp);
|
|
43
|
+
}
|
|
44
|
+
catch (cleanupError) {
|
|
45
|
+
if (cleanupError.code !== "ENOENT") {
|
|
46
|
+
report(`Could not remove atomic-write temporary file ${tmp}: ${cleanupError.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export const atomicWrite = createAtomicWriter();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
targets;
|
|
5
|
+
constructor(message, status, code, targets) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.targets = targets;
|
|
10
|
+
this.name = "ApiError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function record(value) {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
15
|
+
throw new Error("The review server returned an invalid response.");
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
export function createReviewApi({ token, fetch: request = globalThis.fetch, }) {
|
|
20
|
+
const pending = new Set();
|
|
21
|
+
let disposed = false;
|
|
22
|
+
async function send(path, options = {}) {
|
|
23
|
+
if (disposed)
|
|
24
|
+
throw new Error("The review connection is closed.");
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
27
|
+
if (options.signal?.aborted)
|
|
28
|
+
abort();
|
|
29
|
+
else
|
|
30
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
31
|
+
pending.add(controller);
|
|
32
|
+
try {
|
|
33
|
+
const headers = new Headers(options.headers);
|
|
34
|
+
if (!headers.has("content-type"))
|
|
35
|
+
headers.set("content-type", "application/json");
|
|
36
|
+
headers.set("x-doc-review-token", token);
|
|
37
|
+
const response = await request(path, { ...options, headers, signal: controller.signal });
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
let detail = {};
|
|
40
|
+
try {
|
|
41
|
+
detail = record(await response.json());
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (controller.signal.aborted)
|
|
45
|
+
throw error;
|
|
46
|
+
// Preserve the HTTP failure even when the error body is not JSON.
|
|
47
|
+
}
|
|
48
|
+
throw new ApiError(typeof detail.error === "string" ? detail.error : `Request failed (${response.status})`, response.status, typeof detail.code === "string" ? detail.code : undefined, Array.isArray(detail.targets) ? detail.targets : []);
|
|
49
|
+
}
|
|
50
|
+
return await response.json();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
pending.delete(controller);
|
|
54
|
+
options.signal?.removeEventListener("abort", abort);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function requestJson(path, options, decode) {
|
|
58
|
+
const value = await send(path, options);
|
|
59
|
+
return decode ? decode(value) : value;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
request: requestJson,
|
|
63
|
+
dispose() {
|
|
64
|
+
if (disposed)
|
|
65
|
+
return;
|
|
66
|
+
disposed = true;
|
|
67
|
+
for (const controller of pending)
|
|
68
|
+
controller.abort(new Error("Review ended"));
|
|
69
|
+
pending.clear();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
import { isPageResponse } from "./contracts/page.js";
|
|
74
|
+
export function decodePage(value) {
|
|
75
|
+
if (!isPageResponse(value))
|
|
76
|
+
throw new Error("The review server returned an invalid page response.");
|
|
77
|
+
return value;
|
|
78
|
+
}
|