@jayyuen66/dsh-ocr-review 0.1.6 → 0.1.7

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 CHANGED
@@ -32,6 +32,11 @@ English · [中文](README.zh-CN.md)
32
32
  - When either is missing the card shows the degradation reason while tools and settings keep working.
33
33
  - The host must be `>= 0.2.1-rc.2`. That floor sits in `engines.dsh`, which is documentation only — no loader or installer reads it. What actually gates installation is the exact spelling of the four peer pins: the host compares every peer range against its own runtime version (prereleases participate) and refuses with `incompatible-version` when one does not match. On a machine still running dsh 0.2.1-rc.2, upgrade dsh first, or record an exemption for this package in the profile's `compatibility.json` (`dsh plugin --profile <profile> allow-version <name@version> --dsh-version <runtime version> --accept-risk`). This bundle imports no symbol from the `@deepseek-ai/dsh` package itself (every service face comes from the `@deepseek-ai/dsh-*` sub-packages), so it is not declared as a dependency of any kind.
34
34
 
35
+
36
+ ## Shared version floor
37
+
38
+ If your profile pins `@jayyuen66/dsh-plugin-shared` with a pnpm `overrides` entry, that pin must be at least the `@jayyuen66/dsh-plugin-shared` version this package declares. A lower pin installs a copy without the names this package imports at runtime, and the host then reports `N entries did not activate` with `does not provide an export named ...` when it boots -- the package installs fine and never loads, and no per-repo CI run can see it.
39
+
35
40
  ## External CLI status and version
36
41
 
37
42
  One judgement, `resolveOcrAvailability` in `lib/cli.ts`, answers "is there an `ocr` here, which copy, and how was it installed": the first executable found on `PATH` (with `PATHEXT` extensions on Windows), otherwise a copy inside this package's own dependency tree. "How it was installed" is read off the file the symlink in `bin` actually points at (`realpathSync`): anything under a `node_modules` is an npm-style global install, anything under `Cellar`/`Homebrew` is a formula, and neither of those is reported as unrecognised rather than guessed — the brew-vs-npm order matters because a formula-installed Node puts its own global npm packages under the same `homebrew` prefix. It is pure filesystem — `statSync(...).isFile()`, never a spawn — and it answers **candidate**, not **runnable**: a file that exists but cannot execute (no executable bit, or the bundled entry present while its platform binary is missing) passes the probe and is reported by the real spawn instead, which is why the "command not found" wording is kept as a separate fallback.
@@ -191,7 +196,7 @@ The external `ocr` subprocess started by `wait: false` is also registered as an
191
196
  ## Development
192
197
 
193
198
  - `npm run check` is the single gate to run before publishing: typecheck (both tsconfigs) + `oxlint` + rebuild both bundles + `npm test` (vitest with coverage) + `oxfmt --check`.
194
- - `npm test` runs with `--coverage` under a **100% global threshold** (lines/statements/branches/functions) — any line or branch not pinned by a test turns the suite red by design.
199
+ - `npm test` runs with `--coverage` under a **100% threshold** (lines/statements/branches/functions) over the coverage surface this repo opts into (`coverageInclude` in `vitest.config.ts`: `host.ts` + `lib/**`). Anything inside that surface not pinned by a test turns the suite red by design; the client half (`src/**`) is deliberately outside it, since those modules are exercised through the bundle and the settings-card tests rather than as units.
195
200
  - `npm run build` / `npm run build:client` produce the published `host.js` / `client.js`. After editing `host.ts`, `lib/*` or `src/*` they must be rebuilt (`test/build-host.test.ts` pins bundle freshness against the sources, so a stale bundle fails the suite). Never hand-edit the bundles.
196
201
  - The plugin source is TypeScript loaded directly by the host's cordis loader (Node ≥ 22.18 type stripping); the bundles exist for the published npm surface.
197
202
 
package/README.zh-CN.md CHANGED
@@ -60,6 +60,11 @@ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
60
60
  - 包在公共 npm 上,安装不需要凭据。
61
61
  - 发布面只含 `host.js`、`client.js`、`cordis.patch.yml`、`scripts` 与 `THIRD_PARTY_NOTICES.md`(随包运行时依赖的第三方许可证;`prepack` 重建两个 bundle),源码仓见 package.json 的 `repository.url`。
62
62
 
63
+
64
+ ## shared 的版本下限
65
+
66
+ 如果你的 profile 用 pnpm `overrides` 把 `@jayyuen66/dsh-plugin-shared` 钉在某个版本,那个钉住的值必须不低于本包声明的 `@jayyuen66/dsh-plugin-shared` 版本。钉得更低就会装到一份缺少本包运行期所导入的名字的产物,宿主起来时报 `N entries did not activate` 加 `does not provide an export named ...`——包装得上、永远不生效,而各仓单仓 CI 看不见这一类。
67
+
63
68
  ## 外部 CLI 的安装与升级
64
69
 
65
70
  本包只 spawn `ocr`,不安装它。两条命令任选:
@@ -191,7 +196,7 @@ npm i -g @alibaba-group/open-code-review@1.13.0 # 钉在本包核对的那
191
196
  ## 开发
192
197
 
193
198
  - `npm run check` 是发布前的一条门:typecheck(两侧 tsconfig)+ `oxlint` + 重建两个 bundle + `npm test`(vitest 带 coverage)+ `oxfmt --check`。
194
- - `npm test` 带 `--coverage`,全局阈值 **100%**(lines/statements/branches/functions)——任何一行或分支没被用例钉住,测试套件当场红,这是本仓的既定质量门。
199
+ - `npm test` 带 `--coverage`,阈值 **100%**(lines/statements/branches/functions),但分母是本仓 `vitest.config.ts` 里 `coverageInclude` 选定的那一面(`host.ts` + `lib/**`)——那一面里任何一行或分支没被用例钉住就当场红。客户端半(`src/**`)**刻意在分母外**:那几份模块是经 bundle 与设置卡用例覆盖的,不是按单元测的。
195
200
  - `npm run build` / `npm run build:client` 产出发布物 `host.js` / `client.js`;改动 `host.ts`、`lib/*` 或 `src/*` 后必须重建(`test/build-host.test.ts` 钉了产物与源码的新鲜度,过期产物会让套件变红)。发布物不手改。
196
201
  - 插件源码是 TS,由宿主的 cordis Loader 直接加载(Node ≥ 22.18 类型剥离);bundle 只为 npm 发布面存在。
197
202
 
package/client.js CHANGED
@@ -37,7 +37,7 @@ const UI_MESSAGES = {
37
37
  noProvider: "当前未配置 provider",
38
38
  noReason: "宿主未给出原因(重启 dsh 或重试可恢复)",
39
39
  readProvidersFailed: "未能从 dsh 配置服务读取 provider 列表:{reason}",
40
- endpointVerifyFailed: "装载期端点自检失败(配置仍可修改):{reason}",
40
+ endpointVerifyFailed: "装载期端点自检未通过:{reason}(这条是端点给的回执,不是本插件的故障;配置照样能改,也可点「测试连接」重跑)",
41
41
  applied: "已应用:{provider} / {model}\n{test}",
42
42
  notVerified: "(未自动验证,可点「测试连接」手动验证)",
43
43
  llmTestPassed: "✓ ocr llm test 通过",
@@ -63,7 +63,7 @@ const UI_MESSAGES = {
63
63
  maxCommentsLabel: "摘要评论条数 (maxComments)",
64
64
  maxCommentsHint: "返回给模型的 top 评论条数;0 = 全部(含未锚定行,输出大)(默认 12)",
65
65
  timeoutLabel: "运行超时 (timeoutMinutes)",
66
- timeoutHint: "前台墙钟超时(分钟);0 = 宿主上限(shell.maxTimeoutMs,当前 60 分钟);更长的评审用工具 wait=false 后台模式(不设宿主 deadline,2 小时后兜底回收)(默认 0)",
66
+ timeoutHint: "前台墙钟超时(分钟);0 = 宿主上限(shell.maxTimeoutMs,当前 60 分钟);更长的评审用工具 wait=false 后台模式(不设宿主 deadline,兜底窗口看设置项 ocrBackgroundMaxMs,默认 0 = 不设窗口)(默认 0)",
67
67
  configPathLabel: "OCR 配置文件 (ocrConfigPath)",
68
68
  configPathHint: "外部 open-code-review CLI 自己的配置位置;留空 = ~/.opencodereview/config.json。自定义时必须是 <X>/.opencodereview/config.json 布局(OCR 只认该形状,插件经 HOME 重定向让它生效)",
69
69
  settingsLoading: "设置加载中,稍候可改",
@@ -121,7 +121,7 @@ const UI_MESSAGES = {
121
121
  noProvider: "No provider configured yet",
122
122
  noReason: "the host gave no reason (restart dsh or retry to recover)",
123
123
  readProvidersFailed: "Could not read the provider list from the dsh settings service: {reason}",
124
- endpointVerifyFailed: "Startup endpoint check failed (the config is still editable): {reason}",
124
+ endpointVerifyFailed: "Startup endpoint check did not pass: {reason} (this is the endpoint's reply, not a fault of this plugin; the config stays editable, and “Test connection” reruns it)",
125
125
  applied: "Applied: {provider} / {model}\n{test}",
126
126
  notVerified: "(not verified automatically — use “Test connection” to check manually)",
127
127
  llmTestPassed: "✓ ocr llm test passed",
@@ -147,7 +147,7 @@ const UI_MESSAGES = {
147
147
  maxCommentsLabel: "Summary comment cap (maxComments)",
148
148
  maxCommentsHint: "Top comments returned to the model; 0 = all (including unanchored lines, large output) (default 12)",
149
149
  timeoutLabel: "Run timeout (timeoutMinutes)",
150
- timeoutHint: "Foreground wall-clock timeout in minutes; 0 = host cap (shell.maxTimeoutMs, currently 60 min); for longer reviews use the tool's wait=false background mode (no host deadline, reclaimed by a 2-hour backstop) (default 0)",
150
+ timeoutHint: "Foreground wall-clock timeout in minutes; 0 = host cap (shell.maxTimeoutMs, currently 60 min); for longer reviews use the tool's wait=false background mode (no host deadline; the backstop window is the ocrBackgroundMaxMs setting, default 0 = none) (default 0)",
151
151
  configPathLabel: "OCR config file (ocrConfigPath)",
152
152
  configPathHint: "Where the external open-code-review CLI keeps its own config; empty = ~/.opencodereview/config.json. A custom value must follow the <X>/.opencodereview/config.json layout (OCR only reads that shape; the plugin makes it effective via a HOME redirect)",
153
153
  settingsLoading: "settings still loading — editable in a moment",
package/host.js CHANGED
@@ -41,15 +41,19 @@ const MESSAGES = {
41
41
  missingRequiredArgs: "ocr {name}: 缺少必填参数 {keys}(缺失即拒绝,不产生副作用)",
42
42
  bodyMustBeObject: "body 必须是 JSON 对象",
43
43
  outputFileMissing: "ocr 未写出 --output 文件({path}):{cause}——请确认 ocr 版本支持 --output,或用 ocr_session 查询会话结果",
44
+ outputFileTooLarge: "ocr 的 --output 文件过大({path},{size} 字节 > {limit} 字节上限):不整份读进内存。请缩小审查范围(exclude / --commit / 更小的目录)后重试,或用 ocr_session 查这一场的结果",
45
+ failureCoverage: "\n这一场未完成的文件(取自 OCR 已发布的 --output):{items};完整清单见 ocr_session(action=comments/show)",
44
46
  settingsServiceGone: "{ns} 所在的设置服务在读取过程中已不可用",
45
47
  repoRequired: "repo 必填,且为 Git 仓库绝对路径",
46
48
  repoRequiredUnknownWorkspace: "repo 必填:当前会话工作区未知,请显式传入 Git 仓库绝对路径",
47
49
  repoMustBeAbsolute: "repo 必须是绝对路径(以 / 开头):{root}",
48
50
  noNulBytes: "{name} 不能包含 NUL 字节",
51
+ listTruncated: "{name} 超过 {limit} 条上限,已丢弃 {dropped} 条——审查范围会因此小于你给的清单",
49
52
  mustBeString: "{name} 必须是字符串(收到 {received})",
50
53
  listItemsMustBeString: "{name} 数组元素必须是字符串",
51
54
  mustBeStringOrList: "{name} 必须是字符串或字符串数组",
52
55
  intFlagMustBeInt: "{flag} 需要为 ≥{min} 的整数,收到 {received}",
56
+ formatNotJson: "本包只支持 --format json(收到 {format}):text/sarif 没有解析层,交下去只会把参数错报成「OCR 输出解析失败」",
53
57
  scopeBranchCommitExclusive: "scope=branch/from/to 与 commit 互斥(一次只能选一种审查范围)",
54
58
  branchNeedsFromAndTo: "scope=branch 需要同时提供 from 与 to",
55
59
  commitNeedsValue: "scope=commit 需要 commit 参数",
@@ -114,13 +118,13 @@ const MESSAGES = {
114
118
  paramBackground: "需求/业务上下文(官方 skill 推荐:调用前先用 git log/diff/提交信息快速分析变更意图,提取 3-5 句业务背景传入,是提升评审质量最有效的参数;从其它 agent 调用时始终携带 PR 描述/需求)",
115
119
  paramBackgroundShort: "需求/业务上下文",
116
120
  paramBackgroundPreview: "业务上下文(会出现在输出中供审查参考)",
117
- paramExclude: "gitignore 风格排除模式(可多值)",
118
- paramExcludeShort: "排除模式",
119
- paramWait: "true(缺省)=等待完成并返回聚合摘要;false=后台启动立即返回(兜底窗口 ocrBackgroundMaxMs 默认 2 小时),用 ocr_session 轮询结果。两种模式都带宿主回收守护:dsh 退出(含 kill -9)时 OCR 进程一并结束",
121
+ paramExclude: "gitignore 风格排除模式(可多值;上限 2000 条,超出会直接报错而不是静默丢弃)",
122
+ paramExcludeShort: "排除模式(上限 2000 条,超出会报错)",
123
+ paramWait: "false(缺省)=后台启动、立即返回作业 id,用 ocr_session 轮询结果——一次 review 是分钟级长任务,阻塞等待会占住整轮;true=前台阻塞到完成并直接返回聚合摘要。后台模式的兜底窗口由设置项 ocrBackgroundMaxMs 给出(默认 0 = 不设窗口)。两种模式都带宿主回收守护:dsh 退出(含 kill -9)时 OCR 进程一并结束",
120
124
  paramProvider: "本次运行临时指定已配置 provider",
121
125
  paramModel: "本次运行临时覆盖 model",
122
126
  paramResume: "从之前中断的会话 id 恢复(会话 id 在返回结果里)",
123
- paramScanPath: "仓库相对目录或文件(缺省整仓,可多值)",
127
+ paramScanPath: "仓库相对目录或文件(缺省整仓,可多值;上限 2000 条,超出会直接报错)",
124
128
  paramBatch: "分批策略",
125
129
  paramPaths: "待查询的仓库相对文件路径(至少 1 个,最多 200)",
126
130
  paramSessionAction: "list:列最近会话(缺省)/ show:查看单个 / comments:取评论",
@@ -131,6 +135,7 @@ const MESSAGES = {
131
135
  tuningConcurrency: "并发{unit}数(OCR --concurrency;缺省不传=OCR 原生 8)",
132
136
  tuningTimeout: "每{unit}任务超时分钟(OCR --timeout;0=不限时;缺省不传=OCR 原生 15)",
133
137
  tuningMaxTools: "每{unit}工具调用轮数上限(OCR --max-tools;0=模板默认;1-49 会被 OCR 抬到 50,帮助文本 min 50;缺省不传=模板默认)",
138
+ tuningMaxToolsScan: "每{unit}工具调用轮数上限(OCR --max-tools;0=模板默认;只在大于模板默认值时生效,不抬到 50;缺省不传=模板默认)",
134
139
  tuningMaxTokens: "每{unit}提示词 token 上限(OCR --max-tokens;0=配置/模板默认;缺省不传=模板默认)",
135
140
  tuningMaxTokensBudget: "本次运行 total token(input+output)预算闸门,超出即停止派发部分结果(OCR --max-tokens-budget;0=不限;缺省不传=不限)",
136
141
  previewHint: "对每个 reviewable 文件:用 git diff <merge_base>..<to> -- <path>(branch)/git show <commit> -- <path>(commit)/ git diff HEAD -- <path> 或直接读文件(workspace 新文件)取 diff。",
@@ -185,15 +190,19 @@ const MESSAGES = {
185
190
  missingRequiredArgs: "ocr {name}: missing required parameter(s) {keys} (rejected outright, no side effects)",
186
191
  bodyMustBeObject: "body must be a JSON object",
187
192
  outputFileMissing: "ocr did not write the --output file ({path}): {cause} — check that your ocr version supports --output, or query the session with ocr_session",
193
+ outputFileTooLarge: "the ocr --output file is too large ({path}, {size} bytes > {limit}-byte cap): refusing to read it whole. Narrow the review scope (exclude / --commit / smaller directory) and retry, or read this run's results with ocr_session",
194
+ failureCoverage: "\nfiles that did not finish in this run (from the --output OCR already published): {items}; full list via ocr_session(action=comments/show)",
188
195
  settingsServiceGone: "the settings service holding {ns} became unavailable while being read",
189
196
  repoRequired: "repo is required and must be the absolute path of a Git repository",
190
197
  repoRequiredUnknownWorkspace: "repo is required: the current session workspace is unknown, pass the absolute path of a Git repository explicitly",
191
198
  repoMustBeAbsolute: "repo must be an absolute path (starting with /): {root}",
199
+ listTruncated: "{name} exceeds the {limit}-item cap; {dropped} item(s) were dropped - the review will cover less than the list you passed",
192
200
  noNulBytes: "{name} must not contain NUL bytes",
193
201
  mustBeString: "{name} must be a string (got {received})",
194
202
  listItemsMustBeString: "the items of {name} must be strings",
195
203
  mustBeStringOrList: "{name} must be a string or an array of strings",
196
204
  intFlagMustBeInt: "{flag} must be an integer ≥ {min}, got {received}",
205
+ formatNotJson: "this package supports --format json only (got {format}): text/sarif have no parser here, so passing them through reports a bad parameter as an OCR output failure",
197
206
  scopeBranchCommitExclusive: "scope=branch/from/to and commit are mutually exclusive (only one review scope at a time)",
198
207
  branchNeedsFromAndTo: "scope=branch requires both from and to",
199
208
  commitNeedsValue: "scope=commit requires the commit parameter",
@@ -258,13 +267,13 @@ const MESSAGES = {
258
267
  paramBackground: "Requirements/business context (official skill recommends: analyse the change intent via git log/diff/commit messages first and pass 3-5 sentences of context — the single most effective parameter for review quality; always carry the PR description/requirements when called from another agent)",
259
268
  paramBackgroundShort: "Requirements/business context",
260
269
  paramBackgroundPreview: "Business context (it shows up in the output for review reference)",
261
- paramExclude: "gitignore-style exclusion patterns (multiple allowed)",
262
- paramExcludeShort: "Exclusion patterns",
263
- paramWait: "true (default) = wait for completion and return the aggregated summary; false = start in the background and return immediately (reclaimed when the backstop window ocrBackgroundMaxMs, 2 hours by default, elapses), poll with ocr_session. Both modes carry the host reaper guard: when dsh exits (including kill -9) the OCR process is terminated too",
270
+ paramExclude: "gitignore-style exclusion patterns (multiple allowed; cap 2000 - exceeding it is an error, never a silent drop)",
271
+ paramExcludeShort: "Exclusion patterns (cap 2000; exceeding it errors out)",
272
+ paramWait: "false (default) = start in the background, return the job id immediately and poll with ocr_session - a review is a minutes-long task and blocking the call ties up the whole turn; true = block until it finishes and return the aggregated summary directly. The background backstop window is the ocrBackgroundMaxMs setting (default 0 = none). Both modes carry the host reaper guard: when dsh exits (including kill -9) the OCR process is terminated too",
264
273
  paramProvider: "Temporarily use a configured provider for this run",
265
274
  paramModel: "Temporarily override the model for this run",
266
275
  paramResume: "Resume from a previously interrupted session id (the id is in the result)",
267
- paramScanPath: "Repo-relative directories or files (default: whole repo, multiple allowed)",
276
+ paramScanPath: "Repo-relative directories or files (default: whole repo, multiple allowed; cap 2000 - exceeding it is an error, never a silent drop)",
268
277
  paramBatch: "Batching strategy",
269
278
  paramPaths: "Repo-relative file paths to look up (at least 1, at most 200)",
270
279
  paramSessionAction: "list: recent sessions (default) / show: one session / comments: comment list",
@@ -275,6 +284,7 @@ const MESSAGES = {
275
284
  tuningConcurrency: "Concurrent {unit} count (OCR --concurrency; unset = OCR native 8)",
276
285
  tuningTimeout: "Per-{unit} task timeout in minutes (OCR --timeout; 0 = unlimited; unset = OCR native 15)",
277
286
  tuningMaxTools: "Per-{unit} tool-call round cap (OCR --max-tools; 0 = template default; 1-49 is raised to 50 by OCR, help text says min 50; unset = template default)",
287
+ tuningMaxToolsScan: "Per-{unit} tool-call round cap (OCR --max-tools; 0 = template default; only takes effect above the template default, never raised to 50; unset = template default)",
278
288
  tuningMaxTokens: "Per-{unit} prompt token cap (OCR --max-tokens; 0 = config/template default; unset = template default)",
279
289
  tuningMaxTokensBudget: "Total token (input+output) budget gate for this run; dispatch stops once exceeded (OCR --max-tokens-budget; 0 = unlimited; unset = unlimited)",
280
290
  previewHint: "For each reviewable file, get its diff with git diff <merge_base>..<to> -- <path> (branch) / git show <commit> -- <path> (commit) / git diff HEAD -- <path>, or read the file directly (new workspace files).",
@@ -550,12 +560,18 @@ function listSourceItems(value, name, messages) {
550
560
  function takeTrimmedItems(items, name, limit, messages) {
551
561
  const cap = Number.isSafeInteger(limit) && limit > 0 ? limit : DEFAULT_LIST_LIMIT;
552
562
  const out = [];
563
+ let dropped = 0;
553
564
  for (const item of items) {
554
565
  if (item.includes("\0")) throw new Error(format(messages.noNulBytes, { name }));
555
566
  const trimmed = item.trim();
556
- if (trimmed.length > 0) out.push(trimmed);
557
- if (out.length >= cap) break;
567
+ if (trimmed.length > 0 && out.length >= cap) dropped += 1;
568
+ else if (trimmed.length > 0) out.push(trimmed);
558
569
  }
570
+ if (dropped > 0) throw new Error(format(messages.listTruncated, {
571
+ name,
572
+ dropped: String(dropped),
573
+ limit: String(cap)
574
+ }));
559
575
  return out;
560
576
  }
561
577
  /** 字符串数组 → 逗号拼接(--exclude/--path 契约)。
@@ -589,6 +605,11 @@ function resolveOutputFormat(value) {
589
605
  //#region lib/cli.ts
590
606
  /** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
591
607
  * pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
608
+ /** `--exclude` 的模式条数上限。**不是审查范围的上限**,只是防一条失控输入把命令串撑爆;
609
+ * 真实使用里几十条就到头,给到 2000 是为了"尽量不截断"——超了会明确告警,不静默丢。 */
610
+ const EXCLUDE_LIST_LIMIT = 2e3;
611
+ /** `--path` 的路径条数上限(同 EXCLUDE_LIST_LIMIT 的取向:够大 + 超限告警)。 */
612
+ const PATH_LIST_LIMIT = 2e3;
592
613
  const BACKGROUND_FLAG = "--background";
593
614
  /**
594
615
  * 本包的契约是按外部 `ocr` CLI 的哪一版核对的(README 的前置条件、装法建议与工具回显里
@@ -629,29 +650,40 @@ function idValue(value, name, messages) {
629
650
  if (trimmed.includes("\0")) throw new Error(format(messages.noNulBytes, { name }));
630
651
  return trimmed;
631
652
  }
653
+ /** 纯十进制整数的文本形态:数值槽位接受的唯一字符串写法。 */
654
+ const DECIMAL_INTEGER_TEXT = /^\d+$/u;
655
+ /**
656
+ * 数值槽位的**唯一**取值口:只接受 `number` 与纯十进制数字串,其余一律 null。
657
+ *
658
+ * 为什么单列一份:`Number()` 会把 true 当 1、把 [] 当 0、把 "0x10" 当 16、把 "1e2" 当 100,
659
+ * 于是错类型或怪写法的输入会被静默读成**另一个数**。数值 flag 与 session limit 两处槽位
660
+ * 共用这一条判据,包里就不会存在第二种数值口径(此前 session limit 用 `Number()`,
661
+ * `{limit: "0x10"}` 被读成 16)。
662
+ * 非安全整数(1e300、1e21)与非法形态同样 null,由调用点按各自的范围判据拒绝。
663
+ */
664
+ function decimalInteger(value) {
665
+ const isDecimalText = typeof value === "string" && DECIMAL_INTEGER_TEXT.test(value.trim());
666
+ if (typeof value !== "number" && !isDecimalText) return null;
667
+ const num = Number(value);
668
+ return Number.isSafeInteger(num) ? num : null;
669
+ }
632
670
  /**
633
671
  * 数值 flag 忠实透传(桥接层不设钳制——OCR 原生语义:0 = 配置/模板默认或不限,
634
672
  * 负数/非整数由 OCR 侧报错,但桥接层先做同源校验给出更早的错误)。
635
673
  * value 为 undefined/null 时省略 flag(跟随 OCR flag 默认值)。
636
674
  *
637
675
  * 注入面:数值经 `String(num)` 落进命令串(不走 shq)。数值 token 结构上不可能带
638
- * shell 元字符,「只含数字」的形态校验顺带把 1e21 写成 `1e+21` 这类形态也挡住。
676
+ * shell 元字符;安全整数经 String() 只产出 `-?[0-9]+`,而 min ≥ 0 挡掉负号。
639
677
  */
640
678
  function pushIntFlag(parts, flag, value, min, messages) {
641
679
  if (value === void 0 || value === null) return;
642
- const reject = () => {
643
- throw new Error(format(messages.intFlagMustBeInt, {
644
- flag,
645
- min: String(min),
646
- received: describeValue(value)
647
- }));
648
- };
649
- const decimalString = typeof value === "string" && /^\d+$/u.test(value.trim());
650
- if (typeof value !== "number" && !decimalString) reject();
651
- const num = Number(value);
652
- const text = String(num);
653
- if (!Number.isSafeInteger(num) || num < min || !/^\d+$/u.test(text)) reject();
654
- parts.push(flag, text);
680
+ const num = decimalInteger(value);
681
+ if (num === null || num < min) throw new Error(format(messages.intFlagMustBeInt, {
682
+ flag,
683
+ min: String(min),
684
+ received: describeValue(value)
685
+ }));
686
+ parts.push(flag, String(num));
655
687
  }
656
688
  /** 模式参数解析:workspace(默认)/commit/branch 互斥,非法组合与错类型抛错。
657
689
  *
@@ -695,7 +727,7 @@ function pushTextFlag(parts, flag, value, messages) {
695
727
  * buildReviewCommand 在拿到 scopeFlags 结果后前置校验。 */
696
728
  function optionalFlags(args, messages) {
697
729
  const parts = [];
698
- const excludes = commaList(args.exclude, "exclude", 50, messages);
730
+ const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
699
731
  if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
700
732
  pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
701
733
  pushTextFlag(parts, "--provider", args.provider, messages);
@@ -944,6 +976,18 @@ function parseOcrVersion(text) {
944
976
  return /open-code-review\s+v?(?<version>\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/u.exec(text)?.groups?.["version"] ?? null;
945
977
  }
946
978
  /**
979
+ * `--format` 交给 OCR 的取值:本包只认 json——`lib/parse.ts` 的解析层只解 JSON 一份形状。
980
+ *
981
+ * 白名单(`resolveOutputFormat`)管的是注入面,它放行的 text/sarif 在这里被拒:
982
+ * 那两个值没有下游,交下去只会换回一条「OCR 输出解析失败」,把「参数不在本包支持面内」
983
+ * 说成 OCR 的错。`format` 不在任何工具的 parameters 里,走到这里的只有 PTC/直连调用方。
984
+ */
985
+ function jsonOnlyFormat(value, messages) {
986
+ const outputFormat = resolveOutputFormat(value);
987
+ if (outputFormat !== "json") throw new Error(format(messages.formatNotJson, { format: outputFormat }));
988
+ return outputFormat;
989
+ }
990
+ /**
947
991
  * 构造 `ocr review` 命令。--output 固定为调用方给定的临时文件路径(防截断),
948
992
  * --audience agent --format json 是默认(结构化、无进度行)。
949
993
  * outputPath 缺省(后台模式)省略 --output:结果以 OCR 会话记录为准。
@@ -951,13 +995,14 @@ function parseOcrVersion(text) {
951
995
  */
952
996
  function buildReviewCommand(args, outputPath, messages) {
953
997
  const repo = assertAbsoluteRoot(args.repo, messages);
998
+ const outputFormat = jsonOnlyFormat(args.format, messages);
954
999
  const parts = [
955
1000
  ocrCommand(),
956
1001
  "review",
957
1002
  "--audience",
958
1003
  "agent",
959
1004
  "--format",
960
- resolveOutputFormat(args.format)
1005
+ outputFormat
961
1006
  ];
962
1007
  const effort = clampEffort(args.effort, "medium");
963
1008
  parts.push("--effort", effort);
@@ -985,17 +1030,18 @@ function buildReviewCommand(args, outputPath, messages) {
985
1030
  * outputPath 缺省(后台模式)省略 --output:结果以 OCR 会话记录为准。 */
986
1031
  function buildScanCommand(args, outputPath, messages) {
987
1032
  const repo = assertAbsoluteRoot(args.repo, messages);
1033
+ const outputFormat = jsonOnlyFormat(args.format, messages);
988
1034
  const parts = [
989
1035
  ocrCommand(),
990
1036
  "scan",
991
1037
  "--audience",
992
1038
  "agent",
993
1039
  "--format",
994
- resolveOutputFormat(args.format)
1040
+ outputFormat
995
1041
  ];
996
- const paths = commaList(args.path, "path", 100, messages);
1042
+ const paths = commaList(args.path, "path", PATH_LIST_LIMIT, messages);
997
1043
  if (paths.length > 0) parts.push("--path", shq(paths.join(",")));
998
- const excludes = commaList(args.exclude, "exclude", 50, messages);
1044
+ const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
999
1045
  if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1000
1046
  pushTextFlag(parts, "--batch", args.batch, messages);
1001
1047
  pushTextFlag(parts, "--provider", args.provider, messages);
@@ -1027,7 +1073,7 @@ function buildDelegatePreviewCommand(args, messages) {
1027
1073
  "json",
1028
1074
  ...scopeFlags(scopeArgs, messages)
1029
1075
  ];
1030
- const excludes = commaList(args.exclude, "exclude", 50, messages);
1076
+ const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
1031
1077
  if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
1032
1078
  pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
1033
1079
  return {
@@ -1065,10 +1111,8 @@ const SESSION_ACTIONS = [
1065
1111
  * 「只看一条」。数字字符串接受("3"),与 pushIntFlag 的数值槽位同一口径。 */
1066
1112
  function sessionLimit(value, messages) {
1067
1113
  if (value === void 0 || value === null) return 10;
1068
- let num = NaN;
1069
- if (typeof value === "number") num = value;
1070
- else if (typeof value === "string") num = Number(value);
1071
- if (!Number.isInteger(num) || num < 1 || num > 100) throw new Error(format(messages.sessionLimitRange, { received: describeValue(value) }));
1114
+ const num = decimalInteger(value);
1115
+ if (num === null || num < 1 || num > 100) throw new Error(format(messages.sessionLimitRange, { received: describeValue(value) }));
1072
1116
  return num;
1073
1117
  }
1074
1118
  /** 构造 `ocr session list/show/comments` 命令(历史评审会话闭环,Json 输出)。 */
@@ -1176,6 +1220,7 @@ const REVIEW_STATUSES = [
1176
1220
  "success",
1177
1221
  "complete",
1178
1222
  "partial",
1223
+ "failed",
1179
1224
  "completed_with_warnings",
1180
1225
  "completed_with_errors",
1181
1226
  "skipped"
@@ -1539,7 +1584,13 @@ function toDelegateFiles(raw) {
1539
1584
  }).filter((fileEntry) => fileEntry !== null);
1540
1585
  return raw.length > 0 && files.length === 0 ? null : files;
1541
1586
  }
1542
- /** excluded_files 同款门:条目形状与 reviewable 一致,缺 path 即不可用。 */
1587
+ /** excluded_files 同款门:条目形状与 reviewable 一致,缺 path 即不可用。
1588
+ *
1589
+ * 理由的键名以 CLI 侧 `delegatePreviewFileJSON` 为准,那是 `exclude_reason`
1590
+ * (`delegate_cmd.go` 的 `ExcludeReason string \`json:"exclude_reason,omitempty"\``)。
1591
+ * 读 `reason` 是照自己脑补的形状写的:真回执里那个键不存在,`str()` 把缺失读成空串,
1592
+ * 于是「被排除文件及原因」永远只有文件没有原因——schema 与工具描述都在承诺后者。
1593
+ * `reason` 作回退保留:形如 `{"reason":…}` 的旧形态/别家产物仍读得出来。 */
1543
1594
  function toExcludedFiles(raw) {
1544
1595
  if (!Array.isArray(raw)) return [];
1545
1596
  const files = raw.map((entry) => {
@@ -1548,7 +1599,7 @@ function toExcludedFiles(raw) {
1548
1599
  if (entryPath === "") return null;
1549
1600
  return {
1550
1601
  path: entryPath,
1551
- reason: str(entry["reason"])
1602
+ reason: str(entry["exclude_reason"]) || str(entry["reason"])
1552
1603
  };
1553
1604
  }).filter((fileEntry) => fileEntry !== null);
1554
1605
  return raw.length > 0 && files.length === 0 ? null : files;
@@ -2457,6 +2508,10 @@ const LLM_TEST_TIMEOUT_MS = 9e4;
2457
2508
  * 人按下「检测」时跑,不进任何热路径,因此不做成设置项。 */
2458
2509
  const VERSION_PROBE_TIMEOUT_MS = 15e3;
2459
2510
  const STDOUT_MAX_BYTES = 4e5;
2511
+ /** `--output` 落盘结果的读取上限(字节)。**刻意不取 STDOUT_MAX_BYTES**:`--output` 存在的
2512
+ * 理由正是「结果比 stdout 缓冲大」,用 400 KB 卡它等于把正常的大仓评审判死。这一枚只是
2513
+ * 护栏,把「整份读进内存」的失败形态从 OOM 换成一条点名文件的错误。 */
2514
+ const OUT_FILE_MAX_BYTES = 67108864;
2460
2515
  /** 解析失败时回显给模型的原始内容长度上限(UTF-16 码元;已脱敏)。
2461
2516
  * 单位写码元而不是"字符":本常量喂给 `echoRaw`→`truncateEnd`,那一刀按码元数计
2462
2517
  * (一张 emoji = 2 枚),写"字符"会把给模型的说明夸大约一倍。 */
@@ -3232,8 +3287,20 @@ function echoRaw(text, limit) {
3232
3287
  return redactKeyMaterial(truncateEnd(text, limit));
3233
3288
  }
3234
3289
  /** 读 --output 落盘结果。OCR exit 0 却没写出文件(版本漂移/被中途 kill)时给
3235
- * 可行动的错误,而不是把裸 ENOENT 栈丢给模型。 */
3290
+ * 可行动的错误,而不是把裸 ENOENT 栈丢给模型。
3291
+ * 超过 OUT_FILE_MAX_BYTES 的先拒掉:`readFileSync` + `JSON.parse` 是两倍内存的整份读,
3292
+ * 病理级的大回执只该换回一条点名文件的错误。大小探测失败(文件不在)不在这里报——
3293
+ * 交给下面那条 `outputFileMissing` 统一说话。 */
3236
3294
  function readOutFile(outPath, messages) {
3295
+ let size = 0;
3296
+ try {
3297
+ ({size} = statSync(outPath));
3298
+ } catch {}
3299
+ if (size > OUT_FILE_MAX_BYTES) throw new Error(format(messages.outputFileTooLarge, {
3300
+ path: outPath,
3301
+ size: String(size),
3302
+ limit: String(OUT_FILE_MAX_BYTES)
3303
+ }));
3237
3304
  try {
3238
3305
  return readFileSync(outPath, "utf8");
3239
3306
  } catch (error) {
@@ -3244,11 +3311,35 @@ function readOutFile(outPath, messages) {
3244
3311
  }
3245
3312
  }
3246
3313
  /**
3314
+ * 非零退出时把 `--output` 里已经发布的 coverage.failed 明细附到失败详情上。
3315
+ *
3316
+ * 上游的退出契约只对 run-level 失败、或「每个被选中的文件都失败」给非零,而那份 manifest
3317
+ * 照发(`review_cmd.go` 的 reviewResultError:“Partial results are published regardless”)
3318
+ * ——只回 stderr 的话模型读到「N of M selected item(s) failed」,却不知道是哪些文件、
3319
+ * 哪一类失败。读不出或没有失败明细时原样交回那个错误,不改变既有失败形态。
3320
+ *
3321
+ * 这里刻意不复用 `readOutFile`:那条路会给「文件缺失/过大」抛错,而失败详情里再加一层
3322
+ * 「读不到落盘结果」的错误只会盖住 OCR 自己的报错。
3323
+ */
3324
+ function withCoverageDetail(error, outPath, messages) {
3325
+ let text;
3326
+ try {
3327
+ text = readFileSync(outPath, "utf8");
3328
+ } catch {
3329
+ return error;
3330
+ }
3331
+ const items = parseReviewOutput(text, messages).coverage?.failedItems ?? [];
3332
+ if (items.length === 0) return error;
3333
+ const note = format(messages.failureCoverage, { items: redactKeyMaterial(items.join("; ")) });
3334
+ return new Error(`${errorText(error)}${note}`, { cause: error });
3335
+ }
3336
+ /**
3247
3337
  * OCR 原生数值参数的 tool schema 片段(忠实透传语义,桥接层不设钳制)。
3248
- * unit 区分 review(每组)/ scan(每文件)的措辞(取消息表的 unitGroup/unitFile,
3249
- * 语言随设置页偏好走);OCR 侧语义见 shared_flags.go:52-58 与同文件的 registerScanFlags。
3338
+ * unit 区分 review(每组)/ scan(每文件)的措辞;maxToolsTemplate 也按命令分开,因为
3339
+ * 两侧的 --max-tools 语义本就不同:review 把 1-49 抬到 50,scan 只在大于模板默认时生效。
3340
+ * 语言随设置页偏好走;OCR 侧语义见 shared_flags.go 的 registerReviewFlags/registerScanFlags。
3250
3341
  */
3251
- function tuningParams(messages, unit) {
3342
+ function tuningParams(messages, unit, maxToolsTemplate) {
3252
3343
  const of = (template) => ({
3253
3344
  type: "integer",
3254
3345
  description: format(template, { unit })
@@ -3256,7 +3347,7 @@ function tuningParams(messages, unit) {
3256
3347
  return {
3257
3348
  concurrency: of(messages.tuningConcurrency),
3258
3349
  timeoutMinutes: of(messages.tuningTimeout),
3259
- maxTools: of(messages.tuningMaxTools),
3350
+ maxTools: of(maxToolsTemplate),
3260
3351
  maxTokens: of(messages.tuningMaxTokens),
3261
3352
  maxTokensBudget: {
3262
3353
  type: "integer",
@@ -3485,10 +3576,17 @@ function effortOf(value, config, messages) {
3485
3576
  }));
3486
3577
  return clampEffort(value, "medium");
3487
3578
  }
3488
- /** wait 入参闸:undefined → true(前台缺省);布尔透传;其余类型抛错——字符串
3489
- * "false" 若被 `!== false` 当真值处理会静默从后台变前台,吞掉用户的等待语义。 */
3579
+ /**
3580
+ * wait 入参闸:undefined → **false(后台缺省)**;布尔透传;其余类型抛错——字符串
3581
+ * "false" 若被 `!== false` 当真值处理会静默从后台变前台,吞掉用户的等待语义。
3582
+ *
3583
+ * 为什么缺省改成后台:一次 review 是分钟级长任务(真跑过 8 分钟以上),前台缺省会让模型
3584
+ * 在一次工具调用里干等,既占住轮次又容易撞上宿主超时;后台立刻返回作业 id,模型用
3585
+ * `ocr_session` 轮询,等待因此发生在**模型自己的节奏里**而不是一次阻塞调用里。
3586
+ * 想要旧行为(一次拿到结果)显式传 `wait: true` 即可。
3587
+ */
3490
3588
  function waitOf(value, messages) {
3491
- if (value === void 0 || value === null) return true;
3589
+ if (value === void 0 || value === null) return false;
3492
3590
  if (typeof value !== "boolean") throw new TypeError(format(messages.waitMustBeBoolean, { received: describeValue(value) }));
3493
3591
  return value;
3494
3592
  }
@@ -3864,7 +3962,7 @@ function registerReviewTool(host, config, probe, versionSink) {
3864
3962
  items: { type: "string" },
3865
3963
  description: msgs.paramExclude
3866
3964
  },
3867
- ...tuningParams(msgs, msgs.unitGroup),
3965
+ ...tuningParams(msgs, msgs.unitGroup, msgs.tuningMaxTools),
3868
3966
  wait: {
3869
3967
  type: "boolean",
3870
3968
  description: msgs.paramWait
@@ -3910,7 +4008,11 @@ function registerReviewTool(host, config, probe, versionSink) {
3910
4008
  backgroundMaxMs: config.ocrBackgroundMaxMs,
3911
4009
  env: ocrEnv(config)
3912
4010
  });
3913
- await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
4011
+ try {
4012
+ await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
4013
+ } catch (error) {
4014
+ throw withCoverageDetail(error, outPath, runMsgs);
4015
+ }
3914
4016
  const text = readOutFile(outPath, runMsgs);
3915
4017
  const parsed = parseReviewOutput(text, runMsgs);
3916
4018
  if (!parsed.ok) return format(runMsgs.parseFailedWithReason, {
@@ -3973,7 +4075,7 @@ function registerScanTool(host, config, probe, versionSink) {
3973
4075
  type: "string",
3974
4076
  description: msgs.paramModel
3975
4077
  },
3976
- ...tuningParams(msgs, msgs.unitFile),
4078
+ ...tuningParams(msgs, msgs.unitFile, msgs.tuningMaxToolsScan),
3977
4079
  wait: {
3978
4080
  type: "boolean",
3979
4081
  description: msgs.paramWait
@@ -4005,7 +4107,11 @@ function registerScanTool(host, config, probe, versionSink) {
4005
4107
  backgroundMaxMs: config.ocrBackgroundMaxMs,
4006
4108
  env: ocrEnv(config)
4007
4109
  });
4008
- await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
4110
+ try {
4111
+ await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
4112
+ } catch (error) {
4113
+ throw withCoverageDetail(error, outPath, runMsgs);
4114
+ }
4009
4115
  const text = readOutFile(outPath, runMsgs);
4010
4116
  const parsed = parseReviewOutput(text, runMsgs);
4011
4117
  if (!parsed.ok) return format(runMsgs.parseFailedWithReason, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jayyuen66/dsh-ocr-review",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "dsh 工作区代码审查插件:把 open-code-review(ocr CLI)封装为 5 个模型工具 + 设置卡片(从宿主设置服务的 provider 清单选 provider/model 应用到 OCR 配置)。",
5
5
  "keywords": [
6
6
  "AI code review",
@@ -212,13 +212,17 @@ function unquotedValue(raw) {
212
212
  }
213
213
 
214
214
  /**
215
- * 继承来的进程环境层:去首尾空白,空白按「这一层没有值」处理。
215
+ * 继承来的进程环境层:去首尾空白,空白按「这一层没有值」处理;含内嵌换行的值整层作废。
216
+ *
217
+ * 单行是 OCR 的 api_key_cmd 契约(多行 stdout = 硬错误,见文件头),托管文档层已按同一条
218
+ * 判据丢弃——环境层不能是例外:把带内嵌换行的值原样吐出去,用户拿到的是隔了一层的
219
+ * 「resolve LLM endpoint」报错,而这一层本可以当没有值、继续往下回退。
216
220
  * @param {string | undefined} raw
217
221
  * @returns {string | undefined}
218
222
  */
219
223
  function fromInheritedEnv(raw) {
220
224
  const value = typeof raw === "string" ? raw.trim() : "";
221
- return value === "" ? undefined : value;
225
+ return value === "" || /[\r\n]/u.test(value) ? undefined : value;
222
226
  }
223
227
 
224
228
  /**