@erdemtuna/doc-review 0.8.1 → 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 -137
- package/{src → lib}/SKILL.md +28 -4
- 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/{src → lib}/chrome.css +253 -8
- package/lib/chrome.html +196 -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/document-trust.js +2 -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 -12
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2006
- package/src/chrome-session.js +0 -85
- package/src/chrome.html +0 -129
- package/src/cli.js +0 -267
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -77
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -16
- 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/sdk.js +0 -2243
- 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 -1298
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -507
package/README.md
CHANGED
|
@@ -1,170 +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 **View** (`Editing off, comments enabled`) so links, buttons, summaries, tabs, and application controls work normally. The centered mode selector switches to **Edit** (`Direct editing on`) when you want to change content directly. 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
|
-
For writable HTML files, Edit saves direct changes automatically. Markdown and localhost remain editable feedback-only surfaces: their rendered HTML is never written over the source, so click Send and let the agent apply those edits.
|
|
77
|
-
|
|
78
|
-
File and rendered Markdown reviews run with authored scripts and inline handlers
|
|
79
|
-
blocked, an opaque iframe origin, and no popup or download permission. Their
|
|
80
|
-
per-render message capability is rotated for every load and navigation. It is
|
|
81
|
-
kept out of document URLs, HTML attributes, authored DOM, and global JavaScript
|
|
82
|
-
state; the single-use artifact URL loads a same-origin bootstrap module under a
|
|
83
|
-
nonce-based CSP. Relative assets, including a same-artifact `<base>`, resolve
|
|
84
|
-
beside the reviewed file, while review navigation always stays relative to the
|
|
85
|
-
source file. External bases are ignored.
|
|
86
|
-
|
|
87
|
-
Localhost reviews keep `allow-same-origin`, popup, and download compatibility so
|
|
88
|
-
application behavior still works. Because localhost application scripts are
|
|
89
|
-
trusted in that mode, the render capability provides correlation and stale
|
|
90
|
-
message rejection rather than an authorization boundary. The authenticated
|
|
91
|
-
parent still validates links and never exposes the raw-file save route to a
|
|
92
|
-
localhost review.
|
|
93
|
-
|
|
94
|
-
Agent polling returns an immutable `batch_id`. After applying the batch, the
|
|
95
|
-
agent acknowledges that exact receipt and keeps waiting:
|
|
96
|
-
|
|
97
|
-
```sh
|
|
98
|
-
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
|
|
99
33
|
```
|
|
100
34
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
### Feedback reliability and limits
|
|
105
|
-
|
|
106
|
-
`poll` without `--timeout` waits for at most 12 hours. Explicit timeouts include
|
|
107
|
-
server discovery, reconnection, and retry backoff, not just time spent connected.
|
|
108
|
-
Recoverable connection drops are retried; invalid responses, authorization errors,
|
|
109
|
-
and incompatible servers fail visibly. A timeout does not discard feedback: use
|
|
110
|
-
`status` to check it, then start another poll if the review is still wanted.
|
|
111
|
-
|
|
112
|
-
Each edit text or HTML field is limited to 200,000 Unicode code points. Oversized
|
|
113
|
-
edits carry `truncated: true` and `truncated_fields` naming incomplete fields.
|
|
114
|
-
The agent must not use partial text or HTML as a complete replacement or invent
|
|
115
|
-
the rest. It must obtain the full edit from an authoritative source or ask you
|
|
116
|
-
for it before acknowledging the batch.
|
|
117
|
-
|
|
118
|
-
External writes to a Markdown source refresh its rendered baseline without
|
|
119
|
-
clearing unsent feedback. That preserves your edits; it does not automatically
|
|
120
|
-
resolve conflicts with the changed source. HTML autosaves keep their existing
|
|
121
|
-
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.
|
|
122
37
|
|
|
123
|
-
|
|
38
|
+
## From feedback to the next version
|
|
124
39
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
130
50
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
Reload skills or start a fresh agent session afterward. A newer CLI paired with
|
|
134
|
-
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.
|
|
135
53
|
|
|
136
|
-
## What
|
|
54
|
+
## What happens to your edits?
|
|
137
55
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
- **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.
|
|
144
|
-
- **Select a phrase and leave a comment** from the contextual icon, or press Ctrl+Alt+M / Cmd+Option+M.
|
|
145
|
-
- **Comment on an image, chart, control, or section** from its hover or keyboard-focus affordance without taking over its normal click.
|
|
146
|
-
- **Remove elements** without explaining the deletion in chat.
|
|
147
|
-
- **Command-click links** to review multiple pages without losing your feedback.
|
|
148
|
-
- **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 |
|
|
149
61
|
|
|
150
|
-
|
|
62
|
+
Self contained HTML can run inline scripts. For an app with separate script
|
|
63
|
+
dependencies, use its localhost URL instead.
|
|
151
64
|
|
|
152
|
-
|
|
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.
|
|
153
68
|
|
|
154
|
-
|
|
155
|
-
- [`server.js`](src/server.js) runs the local review session.
|
|
156
|
-
- [`sdk.js`](src/sdk.js) handles editing, comments, highlights, and feedback.
|
|
157
|
-
- [`chrome-client.js`](src/chrome-client.js) contains the visual review interface.
|
|
158
|
-
- [`markdown.js`](src/markdown.js) renders Markdown files for review.
|
|
159
|
-
- [`SKILL.md`](src/SKILL.md) teaches Claude Code, Codex, and other agents how to use Doc Review.
|
|
69
|
+
## Learn more
|
|
160
70
|
|
|
161
|
-
|
|
162
|
-
|
|
71
|
+
[Usage guide](https://github.com/erdemtuna/doc-review/blob/main/docs/usage.md):
|
|
72
|
+
setup options, comments, comparisons, limitations, and upgrades.
|
|
163
73
|
|
|
164
|
-
|
|
74
|
+
[Development](https://github.com/erdemtuna/doc-review/blob/main/docs/development.md):
|
|
75
|
+
build, test, architecture, and package checks.
|
|
165
76
|
|
|
166
|
-
|
|
77
|
+
[Releasing](https://github.com/erdemtuna/doc-review/blob/main/RELEASING.md):
|
|
78
|
+
the maintainers' release process.
|
|
167
79
|
|
|
168
|
-
##
|
|
80
|
+
## Credits and license
|
|
169
81
|
|
|
170
|
-
|
|
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).
|
package/{src → lib}/SKILL.md
RENAMED
|
@@ -18,10 +18,19 @@ end it; stop if they cancel or switch to a different task.
|
|
|
18
18
|
|
|
19
19
|
## Review behavior
|
|
20
20
|
|
|
21
|
-
The user reviews your HTML, Markdown, or localhost page in a real browser. It starts in View
|
|
22
|
-
|
|
21
|
+
The user reviews your HTML, Markdown, or localhost page in a real browser. It starts in View.
|
|
22
|
+
Native page controls work; supported self-contained HTML scripts run automatically.
|
|
23
|
+
They can switch to Edit, comment explicitly in either mode,
|
|
23
24
|
and send the whole batch at once.
|
|
24
25
|
|
|
26
|
+
Plain HTML retains direct autosave. Scripted HTML edits are feedback-only:
|
|
27
|
+
apply them to the original source, never serialize the script-modified runtime
|
|
28
|
+
page over the file. Source changes do not require renewed approval.
|
|
29
|
+
Reload without scripts is a temporary recovery option, not permission to write
|
|
30
|
+
a scripted preview over its source. Reloads can wait for unresolved editing work.
|
|
31
|
+
Applications requiring external script dependencies should use localhost review;
|
|
32
|
+
automatic inline execution does not add support for application imports or workers.
|
|
33
|
+
|
|
25
34
|
Markdown files open rendered. Their quotes and edits reference the rendered text,
|
|
26
35
|
and the file itself is never touched — apply every change to the Markdown source,
|
|
27
36
|
keeping its formatting syntax.
|
|
@@ -96,6 +105,17 @@ carried; newer comments and corrections survive.
|
|
|
96
105
|
The response's `next_step` contains the complete acknowledgement command.
|
|
97
106
|
Never acknowledge a different or guessed ID.
|
|
98
107
|
|
|
108
|
+
Acknowledgement means that you handled the feedback; it is separate from
|
|
109
|
+
browser result-capture readiness. Do not wait for history capture before
|
|
110
|
+
acknowledging completed work, and do not acknowledge merely because a page
|
|
111
|
+
looks stable. The browser captures round results automatically when possible.
|
|
112
|
+
Missing comparison data does not prevent the user from sending feedback.
|
|
113
|
+
Captured differences are observations, not proof that you alone authored them.
|
|
114
|
+
Content results may wait for the same identifiable tab as the baseline.
|
|
115
|
+
The user sees a return-to-tab prompt; do not automate tab clicking to force
|
|
116
|
+
capture. Source comparison remains independent. Unknown view identity is
|
|
117
|
+
labelled unverified, not asserted as a whole-application comparison.
|
|
118
|
+
|
|
99
119
|
Repeat 3–4 until the user says they are done.
|
|
100
120
|
|
|
101
121
|
Not sure whether feedback is already waiting — say, at the start of a new turn?
|
|
@@ -173,8 +193,12 @@ One batch covers every page the user visited, grouped by file or localhost URL.
|
|
|
173
193
|
- Find each comment by its `quote`; that exact string is in the file.
|
|
174
194
|
- `kind: "element"` points at a whole block, so `quote` is its label, not body text.
|
|
175
195
|
- Fix every page in `pages`, not just the first.
|
|
176
|
-
- **Do not write a reply.** There is no chat. The user sees
|
|
177
|
-
|
|
196
|
+
- **Do not write a reply.** There is no chat. The user sees the updated page and
|
|
197
|
+
can choose Changes for a fixed Send-to-result comparison. Reload may wait
|
|
198
|
+
for the user to handle unsaved review work. Live result capture may require
|
|
199
|
+
a contextual retry; never describe unavailable history as no changes.
|
|
200
|
+
- Review history uses local content/source snapshots, not screenshots or Git
|
|
201
|
+
commits. Do not create commits or alter repository state to populate history.
|
|
178
202
|
|
|
179
203
|
## Better edit labels (optional)
|
|
180
204
|
|
|
@@ -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
|
+
}
|