dsh-browser-playwright-codex 0.5.4 → 0.6.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/lib/tool.js CHANGED
@@ -3,7 +3,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
3
3
  import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
4
4
  import { BrowserError } from "./errors.js";
5
5
  import { registeredSuffixes } from "./contract.js";
6
- import { getEnabled, publishGates } from "./runtime-state.js";
6
+ import { getEnabled, publishGates, publishLoginSites } from "./runtime-state.js";
7
+ import { summarizeLoginSites } from "./login-sites.js";
7
8
  import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
8
9
  import { SCREENSHOT_SCHEMA, SNAPSHOT_SCHEMA, TABS_SCHEMA } from "./tool-schemas.js";
9
10
  import { parseJsonText, extractionPrompt } from "./extract.js";
@@ -68,11 +69,37 @@ export function apply(ctx, config) {
68
69
  return id === undefined ? 'anonymous' : String(id);
69
70
  };
70
71
  /** Acquire the calling session's live browser session. */
72
+ /**
73
+ * Publish the "which sites look logged in" summary for the settings card. Throttled, and
74
+ * every failure is swallowed: this is a display nicety and must never slow down or break an
75
+ * action. Nothing here is model-visible — it travels only to the settings route.
76
+ */
77
+ let lastLoginPublish = 0;
78
+ const publishLogins = (session) => {
79
+ const now = Date.now();
80
+ if (now - lastLoginPublish < 5000)
81
+ return;
82
+ lastLoginPublish = now;
83
+ void session
84
+ .cookieJar()
85
+ .then(async (rows) => {
86
+ publishLoginSites(summarizeLoginSites(rows, 'live', now, {
87
+ visited: session.visitedHosts(),
88
+ titles: await session.siteTitles(),
89
+ }));
90
+ })
91
+ .catch(() => {
92
+ /* display-only: a failed cookie read must not surface as a tool failure */
93
+ });
94
+ };
71
95
  const acquire = (exec) => {
72
96
  if (!getEnabled()) {
73
97
  throw new BrowserError('DISABLED', 'dsh-browser-playwright-codex is disabled; open Settings → Plugins → 浏览器 and turn it on to use the browser');
74
98
  }
75
- return ctx.browser.acquire(ownerFor(exec), exec.signal);
99
+ return ctx.browser.acquire(ownerFor(exec), exec.signal).then((session) => {
100
+ publishLogins(session);
101
+ return session;
102
+ });
76
103
  };
77
104
  const services = () => {
78
105
  const out = {};
@@ -137,46 +164,69 @@ export function apply(ctx, config) {
137
164
  }));
138
165
  ctx.tools.register(defineTool({
139
166
  name: p('click'),
140
- description: 'Click the element with the given ref from the latest snapshot and return the new snapshot. ' +
141
- 'Refs go stale after navigation or DOM changes — take a fresh ' +
142
- p('snapshot') +
143
- ' if a ref fails. ' +
144
- 'Prefer ref when the snapshot has one; for a target shown WITHOUT a ref (plain text, e.g. generic "设置", or a menu item ' +
145
- p('hover') +
146
- ' just revealed), pass text instead, copied exactly as rendered — a revealed item may need ' +
147
- p('hover') +
148
- ' first to stay on screen. ' +
149
- 'IRON RULE: after the click, verify in the returned snapshot that the intended effect actually happened (URL, selection, toast, dialog, new content). ' +
150
- 'A click can also succeed without changing this page: when the snapshot shows no change, check ' +
167
+ description: 'Click the target from the latest snapshot — or reveal it without clicking (mode:"hover") — and return the new snapshot. ' +
168
+ 'Best target first: `ref` when the snapshot has one; `text`, a visible label copied exactly as rendered, for a target shown WITHOUT a ref; `x`+`y` viewport CSS pixels only as a last resort (canvas-drawn hit areas). Refs go stale after navigation or DOM changes: take a fresh snapshot if a ref fails. ' +
169
+ 'mode:"hover" only REVEALS content and never activates: use it when a control hides content until the pointer rests on it, especially when the snapshot lacks an element the page obviously should have. A hover returning only means the pointer moved: read the note comparing the actionable ref count before and after; new refs mean pre-hover refs are stale, so act from the NEW refs before an unrelated click closes the menu. If that note reports no new refs, do NOT repeat the hover and do NOT fall back to raw coordinates — hover the parent/container or the sibling that owns the menu, otherwise use a visible alternative or tell the user. ' +
170
+ 'precise:true takes a ref click down the geometric path instead: the element is scrolled into view and clicked at its exact centre through native input, with no screenshot measuring, no devicePixelRatio guesswork. It is the FALLBACK, not a routine alternative — use it only after a ref click reports the element is not actionable, or that the click could not be delivered. ' +
171
+ 'IRON RULE: verify the effect in the returned snapshot (URL, selection, toast, dialog, new content). No change may still be success: check ' +
151
172
  p('tabs') +
152
- ' for a new tab (a target="_blank" link) before concluding failure, and treat a same-page overlay or popup, URL unchanged and no new tab, as success — the returned snapshot is the evidence: compare its refs and content with the pre-click snapshot. ' +
153
- 'The snapshot may open with a landing note naming what received the click: if that is not your target, the click was intercepted — treat it as failed and retarget. ' +
154
- 'If there was no visible effect, do NOT blindly repeat the click and do NOT fall back to ' +
155
- p('click_at') +
156
- ' raw coordinates — inspect the visible state for the blocker or interception, resolve it, then retry the most direct semantic action. ' +
157
- 'If the click raises a native dialog, it stays PENDING and this call returns immediately with the dialog text instead of the page tree — answer it with ' +
173
+ ' for a new tab (a target="_blank" link) before concluding, and treat a same-page overlay as success. ' +
174
+ 'The landing note names what received the click: if that is not your target the click was intercepted — treat it as failed and retarget; do NOT blindly repeat it and do NOT throw raw coordinates at it, but inspect the visible state for the blocker or interception first, resolve that, then retry the most direct semantic action. ' +
175
+ 'A native dialog stays PENDING and this returns immediately with the dialog text; this tool cannot accept it — ' +
158
176
  p('dialog') +
159
- '. This tool has no way to accept a dialog: accepting is a separate, deliberate call.',
177
+ ' is a separate, deliberate call.',
160
178
  parameters: {
161
179
  ref: {
162
180
  type: 'string',
163
- description: 'Element ref (e.g. e12) from the latest snapshot. Use this whenever the snapshot has a ref for the target.',
181
+ description: 'Element ref (e.g. e12) from the latest snapshot. Preferred whenever the snapshot has one.',
164
182
  },
165
183
  text: {
166
184
  type: 'string',
167
- description: 'Visible label of a target shown WITHOUT a ref (plain text, e.g. generic "搜索设置"). Copied exactly as the snapshot renders it; the first visible element whose own text matches is clicked and the landing note names it.',
185
+ description: 'Visible label of a target shown WITHOUT a ref, copied exactly as the snapshot renders it; the first visible element whose own text matches is used and the note names it.',
186
+ },
187
+ x: {
188
+ type: 'integer',
189
+ description: 'Viewport x in CSS pixels, with y: a raw click, mode "click" only, last resort.',
190
+ },
191
+ y: {
192
+ type: 'integer',
193
+ description: 'Viewport y in CSS pixels, with x: a raw click, mode "click" only, last resort.',
194
+ },
195
+ mode: {
196
+ type: 'string',
197
+ enum: ['click', 'hover'],
198
+ description: 'click (default) activates the target; hover only reveals it.',
199
+ },
200
+ precise: {
201
+ type: 'boolean',
202
+ description: 'Ref clicks only: click the geometric centre through native input instead of the standard click.',
168
203
  },
169
204
  },
170
205
  output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
171
206
  async execute(args, exec) {
172
207
  const session = await acquire(exec);
173
- if (args.ref !== undefined && args.ref !== '') {
174
- return snapshotValue(await session.click(args.ref, exec.signal));
208
+ const mode = args.mode ?? 'click';
209
+ if (mode !== 'click' && mode !== 'hover') {
210
+ throw new BrowserError('PARAM_MISSING', 'browser_click takes mode "click" or "hover", got: ' + String(args.mode));
211
+ }
212
+ const ref = args.ref !== undefined && args.ref !== '' ? args.ref : undefined;
213
+ const text = args.text !== undefined && args.text !== '' ? args.text : undefined;
214
+ const point = typeof args.x === 'number' && typeof args.y === 'number' ? { x: args.x, y: args.y } : undefined;
215
+ if (mode === 'hover') {
216
+ if (ref !== undefined)
217
+ return snapshotValue(await session.hover(ref, exec.signal));
218
+ if (text !== undefined)
219
+ return snapshotValue(await session.hoverText(text, exec.signal));
220
+ throw new BrowserError('PARAM_MISSING', 'browser_click with mode "hover" needs a ref from the latest snapshot, or the visible text label of a trigger that has no ref');
175
221
  }
176
- if (args.text !== undefined && args.text !== '') {
177
- return snapshotValue(await session.clickText(args.text, exec.signal));
222
+ if (ref !== undefined) {
223
+ return snapshotValue(args.precise === true ? await session.clickAtRef(ref, exec.signal) : await session.click(ref, exec.signal));
178
224
  }
179
- throw new BrowserError('PARAM_MISSING', 'browser_click needs either a ref from the latest snapshot or the visible text label of a target that has no ref');
225
+ if (text !== undefined)
226
+ return snapshotValue(await session.clickText(text, exec.signal));
227
+ if (point !== undefined)
228
+ return snapshotValue(await session.clickAt(point.x, point.y, exec.signal));
229
+ throw new BrowserError('PARAM_MISSING', 'browser_click needs a target: a ref from the latest snapshot, the visible text label of a target that has no ref, or both x and y viewport coordinates');
180
230
  },
181
231
  }));
182
232
  ctx.tools.register(defineTool({
@@ -207,71 +257,10 @@ export function apply(ctx, config) {
207
257
  return snapshotValue(await session.resolveDialog(args.accept, args.promptText, exec.signal));
208
258
  },
209
259
  }));
210
- ctx.tools.register(defineTool({
211
- name: p('click_at'),
212
- description: 'Precise native click, two modes. Ref mode (prefer it): pass ref — the element is scrolled into view, awaited for geometry stability, and clicked at its exact geometric centre through native input: no screenshot measuring, no devicePixelRatio guesswork. ' +
213
- 'Raw mode: pass x/y viewport CSS pixels to click a raw point with no element involvement. ' +
214
- 'This is the FALLBACK, not a routine alternative: try ' +
215
- p('click') +
216
- ' (including its text mode) first, and use ref mode only after a click reports the element is not actionable or the click could not be delivered. Raw x/y is a last resort for targets no ref resolves (canvas-drawn hit areas): raw clicks still return a landing note saying what they hit, so re-snapshot and confirm. ' +
217
- 'IRON RULE: verify the effect in the returned snapshot; if nothing changed, do NOT repeat blindly and do NOT throw more raw coordinates at it — find the blocker or interception first, then retry the most direct semantic action. ' +
218
- 'The snapshot may open with a landing note naming what received the click, or — when an intercepted ref-mode target had to fall back to a bare click at its centre — a CAUTION note: if the click was intercepted, treat it as failed and retarget.',
219
- parameters: {
220
- ref: {
221
- type: 'string',
222
- description: 'Element ref (e.g. e12) from the latest snapshot. If given, its geometric centre is clicked; x/y are ignored.',
223
- },
224
- x: { type: 'integer', description: 'Raw-mode viewport x in CSS pixels (required only when ref is omitted).' },
225
- y: { type: 'integer', description: 'Raw-mode viewport y in CSS pixels (required only when ref is omitted).' },
226
- },
227
- output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
228
- async execute(args, exec) {
229
- const session = await acquire(exec);
230
- if (args.ref !== undefined && args.ref !== '') {
231
- return snapshotValue(await session.clickAtRef(args.ref, exec.signal));
232
- }
233
- if (typeof args.x !== 'number' || typeof args.y !== 'number') {
234
- throw new BrowserError('PARAM_MISSING', 'browser_click_at needs a ref, or both x and y viewport coordinates');
235
- }
236
- return snapshotValue(await session.clickAt(args.x, args.y, exec.signal));
237
- },
238
- }));
239
- ctx.tools.register(defineTool({
240
- name: p('hover'),
241
- description: 'Hover the element with the given ref — or, for a trigger the snapshot gave no ref, the visible element whose own label equals text — and return the new snapshot. ' +
242
- 'Hover only REVEALS content and never activates anything: use ' +
243
- p('click') +
244
- ' to open, select or submit. Use it when a control hides content until the pointer rests on it (submenus, dropdowns, account menus, tooltips, previews, chart values, player controls) — and especially when the snapshot lacks an element the page obviously should have. ' +
245
- 'Prefer ref when the snapshot has one; use text only for a label shown WITHOUT a ref, copied exactly as rendered. Do not hover plain links/buttons you mean to activate, and never hover "just in case". ' +
246
- 'A hover returning only means the pointer moved: the snapshot opens with a note comparing the actionable ref count before and after — read it. New refs mean the revealed content is in this snapshot and pre-hover refs are stale, so pick from the NEW refs and act before any unrelated click closes the menu. ' +
247
- 'IRON RULE: if the note reports no new refs and what you expected is still missing, do NOT repeat the hover and do NOT fall back to ' +
248
- p('click_at') +
249
- ' raw coordinates — hover the parent/container or the sibling that owns the menu; if that also reveals nothing, hover is not implemented here, so use a visible alternative or tell the user.',
250
- parameters: {
251
- ref: {
252
- type: 'string',
253
- description: 'Element ref (e.g. e12) from the latest snapshot. Use this whenever the snapshot has a ref for the trigger.',
254
- },
255
- text: {
256
- type: 'string',
257
- description: 'Visible label of a trigger shown WITHOUT a ref (plain text, e.g. generic "设置"). Copied exactly as the snapshot renders it; the first visible element whose own text matches is hovered and the note names it.',
258
- },
259
- },
260
- output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
261
- async execute(args, exec) {
262
- const session = await acquire(exec);
263
- if (args.ref !== undefined && args.ref !== '') {
264
- return snapshotValue(await session.hover(args.ref, exec.signal));
265
- }
266
- if (args.text !== undefined && args.text !== '') {
267
- return snapshotValue(await session.hoverText(args.text, exec.signal));
268
- }
269
- throw new BrowserError('PARAM_MISSING', 'browser_hover needs either a ref from the latest snapshot or the visible text label of a trigger that has no ref');
270
- },
271
- }));
272
260
  ctx.tools.register(defineTool({
273
261
  name: p('fill'),
274
262
  description: 'Replace the value of the input or select referenced by the latest snapshot. ' +
263
+ '`value` is accepted as an alias of `text`. ' +
275
264
  'For <select> elements the text selects the matching option label. ' +
276
265
  'IRON RULE: after filling, verify in the returned snapshot that the field actually holds the value. ' +
277
266
  'Frameworks can silently reject or transform input (controlled components, contenteditable editors, ' +
@@ -284,12 +273,27 @@ export function apply(ctx, config) {
284
273
  'say so) — understand why it was ignored, then act.',
285
274
  parameters: {
286
275
  ref: { type: 'string', required: true, description: 'Input or select ref (e.g. e7) from the latest snapshot.' },
287
- text: { type: 'string', required: true, description: 'Exact value to fill, or option label for selects.' },
276
+ text: {
277
+ type: 'string',
278
+ description: 'Exact value to fill, or option label for selects. (In browser_click, `text` is a label to click instead.)',
279
+ },
280
+ value: { type: 'string', description: 'Alias of `text`; if both are given, `text` wins.' },
288
281
  },
289
282
  output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
290
283
  async execute(args, exec) {
284
+ // `text` deliberately is NOT `required: true` here, even though in practice you must give it
285
+ // (or `value`). The parameter DSL is a flat property map whose only requiredness is
286
+ // per-property, so it cannot express "exactly one of text/value" — the `oneOf` the compiled
287
+ // JSON Schema supports is value-level, not object-level. The requirement is therefore
288
+ // enforced below, before a session is acquired (a malformed call must not launch a browser),
289
+ // and the message names both spellings. 2026-10-16: a reviewing model passed `value` first
290
+ // and only got `invalid arguments: missing required property "text"` — no way forward.
291
+ const value = args.text ?? args.value;
292
+ if (value === undefined) {
293
+ throw new BrowserError('PARAM_MISSING', 'browser_fill needs the value to type: pass `text` (alias `value`). The `text` here is the value you type — for a visible label to click, use browser_click instead.');
294
+ }
291
295
  const session = await acquire(exec);
292
- return snapshotValue(await session.fill(args.ref, args.text, exec.signal));
296
+ return snapshotValue(await session.fill(args.ref, value, exec.signal));
293
297
  },
294
298
  }));
295
299
  ctx.tools.register(defineTool({
package/lib/types.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Domain types for the browser capability: snapshots, sessions, providers.
3
3
  * @module dsh-browser-playwright-codex/types
4
4
  */
5
+ import type { CookieRow } from './login-sites.ts';
5
6
  /** Page load condition a navigation waits for. */
6
7
  export type LoadState = 'load' | 'domcontentloaded' | 'networkidle';
7
8
  /** One snapshot element. Refs identify actionable elements for later calls. */
@@ -191,6 +192,18 @@ export interface BrowserSession {
191
192
  } | undefined, signal?: AbortSignal): Promise<ScreenshotCapture>;
192
193
  /** Extract the page's bounded data: text, links, forms, frames. */
193
194
  pageData(): Promise<unknown>;
195
+ /**
196
+ * Read this session's cookies for display-only login inference. Implementations must drop
197
+ * `value` before returning: the inference only needs names, domains and lifetime.
198
+ */
199
+ cookieJar(): Promise<readonly CookieRow[]>;
200
+ /**
201
+ * Registrable domains of the pages this browser has open, used to attribute cookies to the site
202
+ * that was visited rather than to whoever set them.
203
+ */
204
+ visitedHosts(): string[];
205
+ /** Page titles by registrable domain, for display only; empty when nothing is open. */
206
+ siteTitles(): Promise<Map<string, string>>;
194
207
  /** Evaluate one JavaScript expression and return its JSON result. */
195
208
  evaluate(expression: string, signal?: AbortSignal): Promise<unknown>;
196
209
  /** Click the element whose visible text matches, by semantic locator. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-browser-playwright-codex",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Browser automation for DeepSeek Harness: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised, and stops for sign-ins, CAPTCHAs and native dialogs instead of confirming for you.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,10 +37,12 @@
37
37
  "README.md",
38
38
  "CHANGELOG.md",
39
39
  "SECURITY.md",
40
+ "NOTICE.md",
40
41
  "locale"
41
42
  ],
42
43
  "scripts": {
43
44
  "build": "node node_modules/typescript/bin/tsc -p tsconfig.json",
45
+ "typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.tests.json",
44
46
  "test": "node --test \"tests/*.test.ts\"",
45
47
  "test:scenarios": "node --test tests/scenarios.test.ts tests/tool-scenarios.test.ts",
46
48
  "test:live": "node --test tests/live-smoke.test.ts",
@@ -50,7 +52,8 @@
50
52
  "cost": "node node_modules/tsx/dist/cli.mjs scripts/measure-prompt-cost.mjs",
51
53
  "format": "node node_modules/prettier/bin/prettier.cjs --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
52
54
  "format:check": "node node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
53
- "prepublishOnly": "npm run build && npm test"
55
+ "prepublishOnly": "npm run build && npm run typecheck && npm test && npm run verify && npm run preflight",
56
+ "preflight": "node scripts/preflight.mjs ."
54
57
  },
55
58
  "dependencies": {
56
59
  "@deepseek-ai/schemastery": "*",
@@ -0,0 +1,192 @@
1
+ /**
2
+ * 发版前置审计(preflight):把"文档对世界说的话"和"包里到底装了什么"一次查干净。
3
+ *
4
+ * 为什么有这个脚本(2026-10-11,用户第二次因此不满):
5
+ * 之前是"只改点不看面"—— 修了 README 的 npm 说法,却没查 SECURITY 的计数、NOTICE 的路径、
6
+ * 发帖草稿里的数字、以及"文档引用的文件/行号在重构后是否还存在"。用户的原话是
7
+ * 「你能不能检查清楚再发新版本啊,别给我检查出来又一堆 bug」。所以这份检查挂在 `prepublishOnly`
8
+ * 上:**不通过就发不出去**,而不是等用户事后发现。
9
+ *
10
+ * 三类输出,语义严格区分:
11
+ * ✗ 必须修 —— 硬错误(文件读不到、清单缺项、路径/行号/链接失效、测试失败、计数与实测不符)
12
+ * ? 待确认 —— 可能是历史、上游或有意为之(例如 "published since v0.5.1"、上游插件的旧包名)
13
+ * ✓ 已一致 —— 已经核对过的基线数字
14
+ *
15
+ * 规则刻意收窄避免噪声(第一版误报过:把 IP 当版本号、把上游文档的版本当我们的、把竞品的工具数
16
+ * 当我们的、把"三个闸门合计 127 token"这个子项当成总成本)。
17
+ *
18
+ * 用法: node preflight.mjs [项目根] 退出码: 0 通过 / 2 有硬错误
19
+ */
20
+ import { execFileSync } from 'node:child_process'
21
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
22
+ import { dirname, join, relative, resolve } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ const root = resolve(process.argv[2] ?? join(dirname(fileURLToPath(import.meta.url)), '..'))
26
+ const problems = []
27
+ const confirm = []
28
+ const ok = []
29
+
30
+ const readOrThrow = (rel) => {
31
+ const p = join(root, rel)
32
+ if (!existsSync(p)) throw new Error('读不到文件(硬错误,不许当通过):' + rel)
33
+ return readFileSync(p, 'utf8')
34
+ }
35
+
36
+ // ── A. 包里装了什么 ───────────────────────────────────────────────────────────
37
+ const packed = new Set(
38
+ JSON.parse(
39
+ execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: root, encoding: 'utf8', shell: true }),
40
+ )[0].files.map((f) => f.path),
41
+ )
42
+ // 这些是"用户从 npm 装到之后必须能看到"的文件:许可证与归属声明(NOTICE)、安全说明、变更记录、
43
+ // 以及让 Gradle 式的 loader 能挂载的 cordis.patch.yml。2026-10-11 的审计就是靠这份清单抓到
44
+ // NOTICE.md 一直没进包(npm 只会自动带上 LICENSE,不会带 NOTICE)。
45
+ const SHOULD_SHIP = [
46
+ 'package.json',
47
+ 'README.md',
48
+ 'CHANGELOG.md',
49
+ 'SECURITY.md',
50
+ 'NOTICE.md',
51
+ 'LICENSE',
52
+ 'cordis.patch.yml',
53
+ 'locale/en.json',
54
+ 'locale/zh.json',
55
+ ]
56
+ for (const f of SHOULD_SHIP) if (!packed.has(f)) problems.push('包里缺文件:' + f)
57
+ if (![...packed].some((p) => p.startsWith('lib/'))) problems.push('包里没有 lib/(插件实际加载的就是它)')
58
+ if ([...packed].some((p) => p.startsWith('src/') || p.startsWith('tests/'))) problems.push('包里不该有 src/ 或 tests/')
59
+ if ([...packed].some((p) => p.endsWith('.bak'))) problems.push('包里有 .bak 残渣')
60
+ ok.push('包内 ' + packed.size + ' 个文件;已确认含 lib/、locale/、cordis.patch.yml,且不含 src/tests/.bak')
61
+
62
+ // ── B. 基线数字(来自命令本身,不是文档自述) ─────────────────────────────────
63
+ const pkg = JSON.parse(readOrThrow('package.json'))
64
+ const cost = execFileSync('npm', ['run', 'cost'], { cwd: root, encoding: 'utf8', shell: true })
65
+ const toolCount = Number((cost.match(/工具数[::]\s*(\d+)/) ?? [])[1])
66
+ const chars = Number((cost.match(/字符总数[::]\s*(\d+)/) ?? [])[1])
67
+ const tokens = Number((cost.match(/粗估 token[::]\s*~?(\d+)/) ?? [])[1])
68
+ if (!toolCount || !chars || !tokens) problems.push('拿不到 npm run cost 的数字')
69
+ else ok.push('成本基线:' + toolCount + ' 工具 / ' + chars + ' 字符 / ~' + tokens + ' token/轮')
70
+
71
+ const suite = execFileSync('node', ['--test', 'tests/*.test.ts'], { cwd: root, encoding: 'utf8', shell: true })
72
+ const n = (re) => Number((suite.match(re) ?? [])[1])
73
+ if (n(/ℹ fail (\d+)/) > 0) problems.push('测试有失败:' + n(/ℹ fail (\d+)/) + ' 条')
74
+ else
75
+ ok.push(
76
+ '测试:' + n(/ℹ tests (\d+)/) + ' / pass ' + n(/ℹ pass (\d+)/) + ' / fail 0 / skipped ' + n(/ℹ skipped (\d+)/),
77
+ )
78
+
79
+ // ── C. 文档断言 ───────────────────────────────────────────────────────────────
80
+ const docFiles = []
81
+ const walk = (dir) => {
82
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
83
+ if (e.name === 'node_modules' || e.name === '.git') continue
84
+ const p = join(dir, e.name)
85
+ if (e.isDirectory()) walk(p)
86
+ else if (e.name.endsWith('.md')) docFiles.push(relative(root, p).replace(/\\/g, '/'))
87
+ }
88
+ }
89
+ walk(root)
90
+ const HISTORY = new Set(['CHANGELOG.md'])
91
+ /** 上游项目(被借鉴的那个插件)自己的文档:里面出现旧包名/旧版本是对的。 */
92
+ const UPSTREAM_DOCS = new Set([
93
+ 'PLUGIN-README.md',
94
+ 'codex-rules.md',
95
+ 'patches/README.md',
96
+ 'docs/articles/focus-stealing.md',
97
+ ])
98
+ const current = docFiles.filter((f) => !HISTORY.has(f))
99
+
100
+ const basenames = new Set()
101
+ for (const dir of ['', 'src', 'lib', 'scripts', 'tests', 'locale', 'patches', 'docs', 'docs/articles']) {
102
+ const abs = dir ? join(root, dir) : root
103
+ if (!existsSync(abs)) continue
104
+ for (const e of readdirSync(abs, { withFileTypes: true })) if (e.isFile()) basenames.add(e.name)
105
+ }
106
+
107
+ for (const f of docFiles) {
108
+ const text = readOrThrow(f)
109
+ const currentDoc = current.includes(f)
110
+ text.split('\n').forEach((line, i) => {
111
+ const where = f + ':' + (i + 1)
112
+ const historical = /(since|since v|起|之前|以后|published|发布|历史|历史值|for the record|v0[.]4)/i.test(line)
113
+
114
+ // C1. 看起来是"本包版本"的数字
115
+ if (currentDoc && !historical) {
116
+ const notOurs =
117
+ /(Node|node|playwright-core|upstream|上游|Chenyu|Codex|scan of|扫描|dsh-plugin-vet|electron)/i.test(line)
118
+ for (const m of line.matchAll(/\bv(\d+\.\d+\.\d+)\b/g)) {
119
+ if (notOurs) break
120
+ if (m[1] !== pkg.version)
121
+ confirm.push(where + ' 提到版本 v' + m[1] + '(当前 ' + pkg.version + ')→ ' + line.trim().slice(0, 80))
122
+ }
123
+ }
124
+
125
+ // C2. 写死的工具数:只有当这句在说"我们"时才核对(竞品对照说的是别人)
126
+ const tc = line.match(/\b(\d{2})\s*(?:个)?\s*(?:browser_\*?\s*)?(?:tools|工具)/i)
127
+ if (currentDoc && tc && Number(tc[1]) >= 10) {
128
+ const aboutUs = /(我们|this package|本包|registered|注册)/i.test(line)
129
+ const aboutThem = /(同类|竞品|其它|其他|competitor|alternative|更轻)/i.test(line)
130
+ if (aboutUs && !aboutThem && Number(tc[1]) !== toolCount)
131
+ problems.push(where + ' 写死工具数 ' + tc[1] + '(实测 ' + toolCount + ')')
132
+ }
133
+
134
+ // C3. 写死的总成本:只核对"总计/常驻"这类句子,且量级要像总数(≈127 这种子项不算)
135
+ const tk = line.match(/\b(\d[\d,]{2,6})\s*(?:tokens?|token)/i)
136
+ if (currentDoc && tk) {
137
+ const value = Number(tk[1].replace(/,/g, ''))
138
+ const aboutTotal = /(常驻|总计|合计|resident|per turn|每轮)/i.test(line)
139
+ if (aboutTotal && value >= 500 && Math.abs(value - tokens) > 200)
140
+ problems.push(where + ' 写死的总 token 数 ' + tk[1] + '(实测 ~' + tokens + ')')
141
+ }
142
+
143
+ // C4. 旧包名:.state.json 是刻意保留的;上游文档里出现是对的
144
+ if (
145
+ /dsh-browser-playwright(?!-codex)/.test(line) &&
146
+ !UPSTREAM_DOCS.has(f) &&
147
+ !/\.state\.json/.test(line) &&
148
+ !/(上游|upstream|Chenyu)/i.test(line)
149
+ ) {
150
+ confirm.push(where + ' 出现旧包名(确认语境)→ ' + line.trim().slice(0, 80))
151
+ }
152
+
153
+ // C5. 反引号里的仓库路径
154
+ for (const m of line.matchAll(/`([A-Za-z0-9._/-]+\.(?:md|ts|js|mjs|cjs|json|yml|yaml))`/g)) {
155
+ const p = m[1]
156
+ if (!p.includes('/') || p.startsWith('~') || p.startsWith('http')) continue
157
+ const abs = p.startsWith('.') ? resolve(root, f, '..', p) : join(root, p)
158
+ if (existsSync(abs)) continue
159
+ if (basenames.has(p.slice(p.lastIndexOf('/') + 1)))
160
+ problems.push(where + ' 路径写错:`' + p + '`(文件在本仓库,但不在这个位置)')
161
+ }
162
+
163
+ // C6. 「文件:行号」引用
164
+ for (const m of line.matchAll(/([A-Za-z0-9_./-]+\.(?:ts|js|mjs|cjs|json|yml))[^\n]{0,12}?[:(]\s*~?:?(\d{3,5})/g)) {
165
+ const abs = join(root, m[1].replace(/^\.\//, ''))
166
+ if (!existsSync(abs)) continue
167
+ const lines = readFileSync(abs, 'utf8').split('\n').length
168
+ if (Number(m[2]) > lines)
169
+ problems.push(where + ' 行号越界:' + m[1] + ':' + m[2] + '(该文件现在 ' + lines + ' 行)')
170
+ }
171
+ })
172
+ // C7. markdown 相对链接
173
+ for (const m of readOrThrow(f).matchAll(/\]\(([^)#\s]+)(?:#[^)]*)?\)/g)) {
174
+ const target = m[1]
175
+ if (/^(https?:|mailto:)/.test(target) || target.startsWith('#')) continue
176
+ if (!existsSync(resolve(root, f, '..', target))) problems.push(f + ' markdown 链接失效:' + target)
177
+ }
178
+ }
179
+
180
+ // ── 输出 ─────────────────────────────────────────────────────────────────────
181
+ console.log('=== ✗ 必须修(硬错误)===')
182
+ console.log(problems.length ? problems.map((p) => ' ✗ ' + p).join('\n') : ' (无)')
183
+ console.log('')
184
+ console.log('=== ? 待确认(历史 / 上游 / 有意为之)===')
185
+ const uniqueConfirm = [...new Set(confirm)]
186
+ console.log(uniqueConfirm.length ? uniqueConfirm.map((c) => ' ? ' + c).join('\n') : ' (无)')
187
+ console.log('')
188
+ console.log('=== ✓ 已核对 ===')
189
+ console.log(ok.map((o) => ' ✓ ' + o).join('\n'))
190
+ console.log('')
191
+ console.log('结论:' + problems.length + ' 处硬错误、' + uniqueConfirm.length + ' 处待确认。')
192
+ process.exitCode = problems.length ? 2 : 0