@jayyuen66/dsh-ocr-review 0.1.4-rc → 0.1.4-rc.1

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
@@ -31,7 +31,7 @@ dsh plugin --profile web add @jayyuen66/dsh-ocr-review
31
31
  - The packages are on the public npm registry, so installation needs no credentials.
32
32
  - The published files are only `host.js`, `client.js`, `cordis.patch.yml` and `scripts` (`prepack` rebuilds both bundles); source repository: see `repository.url` in package.json.
33
33
  - No `@deepseek-ai/*` goes in `dependencies`, and that is not a style preference. Consumers of this package are dsh plugins installed into a profile (`nodeLinker: hoisted`, `autoInstallPeers: false`), so any host package listed under `dependencies` is written into that profile as a **real directory**, coexisting with the copy the host process already loaded. `@deepseek-ai/dsh-tools` keys its `TOOL_RUNTIME_SCHEDULER` with a plain `Symbol` (not `Symbol.for`), so symbol identity is per module instance: the host's `dsh-agent-loop` reads a `ToolRuntime` mounted from the duplicate using its own copy's symbol, gets `undefined`, and **every native tool call throws** `Cannot read properties of undefined (reading 'prepare')` — the whole profile loses its tools. This package's build, typecheck and tests all stay green right up to that point, so `test/publish-manifest.test.ts` pins the invariant in the manifest instead.
34
- - The four host packages this bundle imports as **values** are required (non-optional) `peerDependencies`: `@deepseek-ai/schemastery` (the host's fork of schemastery — only it implements the 0.1.7 `.volatile()` resolution), `@deepseek-ai/dsh-credentials`, `@deepseek-ai/dsh-home-paths` and `@deepseek-ai/dsh-atomic-write`. The host provides them at runtime, and a profile installs no peers, so no duplicate appears. `build-host.mjs` externalizes `dependencies` *and* `peerDependencies`, so `host.js` keeps the bare specifiers unchanged.
34
+ - The four host packages this bundle imports as **values** are required (non-optional) `peerDependencies`: `@deepseek-ai/schemastery` (the host's fork of schemastery — only it implements the 0.1.7 `.volatile()` resolution), `@deepseek-ai/dsh-credentials`, `@deepseek-ai/dsh-home-paths` and `@deepseek-ai/dsh-atomic-write`. The host provides them at runtime, and a profile installs no peers, so no duplicate appears. `build-host.mjs` externalizes `dependencies`, `peerDependencies` *and* `optionalDependencies`, so `host.js` keeps the bare specifiers unchanged.
35
35
  - Those four are repeated in `devDependencies` — that is what lets this repository's own typecheck, build and tests resolve them. Every other `@deepseek-ai/*` is type-only (erased at build, no specifier left in `host.js`) and stays in `devDependencies` alone.
36
36
  - What is left in `dependencies` is genuinely this package's own runtime code: `@jayyuen66/dsh-plugin-shared` and `js-yaml` (the last one used only by `scripts/get-cred.mjs`).
37
37
 
package/README.zh-CN.md CHANGED
@@ -57,7 +57,7 @@ allowBuilds:
57
57
 
58
58
  如果你更希望完全不执行安装期脚本,可以写 `'@alibaba-group/open-code-review': false`:`ocr` 同样可用(launcher 直接从平台包目录取二进制),但宿主 `readPendingBuilds()` 只认值为 `set this to true or false` 的条目,改成 `false` 后官方审批流程就不再能代为授权,只能手工维护。
59
59
  - `dependencies` 里不放任何 `@deepseek-ai/*`,这不是风格偏好。本包的消费方是 dsh 插件,被装进 profile(`nodeLinker: hoisted`、`autoInstallPeers: false`),只要宿主包出现在 `dependencies`,pnpm 就会把它在那颗 profile 里落成**真实目录**,与宿主进程已经加载的那份并存。而 `@deepseek-ai/dsh-tools` 的 `TOOL_RUNTIME_SCHEDULER` 是普通 `Symbol`(不是 `Symbol.for`),符号身份按模块实例计算——宿主 `dsh-agent-loop` 拿自己那份的 Symbol 去读由副本挂载出来的 `ToolRuntime`,得到 `undefined`,于是**每一次原生工具调用都抛** `Cannot read properties of undefined (reading 'prepare')`,整个 profile 的工具一起失效。本包的构建、类型检查与测试在这之前全部照绿,所以判据只能由 `test/publish-manifest.test.ts` 落在 manifest 面。
60
- - 产物里以**值**导入的四枚宿主包是非 optional 的 `peerDependencies`:`@deepseek-ai/schemastery`(宿主 fork 的 schemastery,0.1.7 的 `.volatile()` 解析只在它有实现)、`@deepseek-ai/dsh-credentials`、`@deepseek-ai/dsh-home-paths`、`@deepseek-ai/dsh-atomic-write`。运行期由宿主在场提供,而 profile 不装 peer,所以不产生副本;`build-host.mjs` 对 `dependencies` 与 `peerDependencies` 两张表一律外部化,`host.js` 里的裸说明符因此一字不变。
60
+ - 产物里以**值**导入的四枚宿主包是非 optional 的 `peerDependencies`:`@deepseek-ai/schemastery`(宿主 fork 的 schemastery,0.1.7 的 `.volatile()` 解析只在它有实现)、`@deepseek-ai/dsh-credentials`、`@deepseek-ai/dsh-home-paths`、`@deepseek-ai/dsh-atomic-write`。运行期由宿主在场提供,而 profile 不装 peer,所以不产生副本;`build-host.mjs` 对 `dependencies`、`peerDependencies` 与 `optionalDependencies` 三张表一律外部化,`host.js` 里的裸说明符因此一字不变。
61
61
  - 这四枚同时另列一份进 `devDependencies`——那才是本包自己的 typecheck、build、test 解析得到它们的方式。其余 `@deepseek-ai/*` 全是 type-only(构建即擦除,`host.js` 里不留说明符),只留在 `devDependencies`。
62
62
  - `dependencies` 剩下的确实是本包自己的运行期代码:`@jayyuen66/dsh-plugin-shared` 与 `js-yaml`(后者只被 `scripts/get-cred.mjs` 用)。
63
63
 
package/client.js CHANGED
@@ -1,16 +1,17 @@
1
1
  // GENERATED by buildClient() in build-client.mjs — edit src/client-entry.ts, then run `node build-client.mjs`.
2
2
  window.__ModuleLoader__.load({
3
- id: '@jayyuen66/dsh-ocr-review',
3
+ id: "@jayyuen66/dsh-ocr-review",
4
4
  factory: (require) => {
5
- var module = { exports: {} };
6
- var exports = module.exports;
5
+ 'use strict';
6
+ const module = { exports: {} };
7
+ let exports = module.exports;
7
8
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
8
9
  let react = require("react");
9
10
  //#region src/ui-messages.ts
10
11
  const UI_MESSAGES = {
11
12
  zh: {
12
13
  cardTitle: "open-code-review 代码审查",
13
- cardDescription: "ocr 工具配置:选择 provider/model 应用到 OCR(key 走 api_key_cmd 动态读取,不落明文)+ 审查参数设置",
14
+ cardDescription: "ocr 工具配置:选择 provider/model 应用到 OCR(应用后 key 由 api_key_cmd 动态读取,不再写入明文)+ 审查参数设置",
14
15
  statusReadOnly: "当前作用域只读",
15
16
  statusDirty: "有未保存的修改,点「保存」生效",
16
17
  statusClean: "无未保存的修改",
@@ -18,6 +19,7 @@ const UI_MESSAGES = {
18
19
  saving: "保存中…",
19
20
  revert: "撤销",
20
21
  saveFailed: "保存失败:",
22
+ saveRefused: "未写入(宿主拒收或跳过):{fields}",
21
23
  loadProvidersFailed: "加载 providers 失败:{error}(端点未就绪?重启 dsh 后重试)",
22
24
  loadingProviders: "加载 providers…",
23
25
  noKeySuffix: "(未配置 key)",
@@ -68,7 +70,7 @@ const UI_MESSAGES = {
68
70
  },
69
71
  en: {
70
72
  cardTitle: "open-code-review code review",
71
- cardDescription: "ocr tool setup: pick a provider/model and apply it to OCR (keys are read dynamically via api_key_cmd, never stored in plain text) + review parameters",
73
+ cardDescription: "ocr tool setup: pick a provider/model and apply it to OCR (once applied, keys are read dynamically via api_key_cmd and no longer written in plain text) + review parameters",
72
74
  statusReadOnly: "This scope is read-only",
73
75
  statusDirty: "Unsaved changes — press Save to apply",
74
76
  statusClean: "No unsaved changes",
@@ -76,6 +78,7 @@ const UI_MESSAGES = {
76
78
  saving: "Saving…",
77
79
  revert: "Revert",
78
80
  saveFailed: "save failed: ",
81
+ saveRefused: "not written (host refused or skipped): {fields}",
79
82
  loadProvidersFailed: "failed to load providers: {error} (endpoint not ready? restart dsh and retry)",
80
83
  loadingProviders: "loading providers…",
81
84
  noKeySuffix: " (no key configured)",
@@ -359,7 +362,7 @@ function OcrPluginCard(props) {
359
362
  }
360
363
  }, headRow, chevron);
361
364
  const body = open ? (0, react.createElement)("div", { className: "ocr-card-body" }, children) : null;
362
- return (0, react.createElement)("li", { className: `ocr-card${open ? " ocr-card-open" : ""}` }, header, body);
365
+ return (0, react.createElement)("div", { className: `ocr-card${open ? " ocr-card-open" : ""}` }, header, body);
363
366
  }
364
367
  /** 整数数字输入行(保存条模式:合法输入上报草稿,不直写)。 */
365
368
  function NumberRow(props) {
@@ -827,7 +830,10 @@ async function runTestConnection(deps) {
827
830
  deps.setBusy(false);
828
831
  }
829
832
  }
830
- /** 只写差异字段:touched 值为 undefined → 写 null(= 清回 schema 默认)。 */
833
+ /** 只写差异字段:touched 值为 undefined → 写 null(= 清回 schema 默认)。
834
+ * 受理位必须回来:`set` 回 `false` 表示宿主拒收或跳过了这次写入(值没落盘),
835
+ * 这时不能清 touched、也不能报成功——否则用户看到「已保存」而配置原封未动。
836
+ * 传输失败仍以 rejection 表达。 */
831
837
  async function saveSettingsDraft(deps) {
832
838
  const { t, value, touched } = deps;
833
839
  const keys = diffTouched(touched, value);
@@ -836,11 +842,16 @@ async function saveSettingsDraft(deps) {
836
842
  deps.setSaveError(null);
837
843
  const ops = keys.map(async (field) => {
838
844
  const draft = touched[field];
839
- await deps.set(field, draft === void 0 ? null : draft);
845
+ return deps.set(field, draft === void 0 ? null : draft);
840
846
  });
841
847
  try {
842
- await Promise.all(ops);
848
+ const settled = await Promise.all(ops);
843
849
  deps.setSettingsBusy(false);
850
+ const refused = keys.filter((_field, index) => settled[index] !== true);
851
+ if (refused.length > 0) {
852
+ deps.setSaveError(t("saveRefused", { fields: refused.join(", ") }));
853
+ return;
854
+ }
844
855
  deps.setTouched({});
845
856
  } catch (error) {
846
857
  deps.setSettingsBusy(false);
@@ -1012,13 +1023,7 @@ function apply(ctx) {
1012
1023
  inject: () => ({
1013
1024
  t,
1014
1025
  hooks: { card: store },
1015
- set: async (field, value) => {
1016
- try {
1017
- await scope.set(field, value);
1018
- } catch (error) {
1019
- console.error(`[ocr-review-card] set ${field} failed:`, error);
1020
- }
1021
- }
1026
+ set: (field, value) => scope.set(field, value)
1022
1027
  })
1023
1028
  }, OcrCard);
1024
1029
  });
package/host.js CHANGED
@@ -341,14 +341,15 @@ function scalarMismatch(type, value) {
341
341
  function objectViolations(schema, value, path, walk) {
342
342
  if (!isRecord(value)) return [`${path}: expected object`];
343
343
  const { properties, required, additionalProperties } = schema;
344
+ const declared = (key) => properties !== void 0 && Object.hasOwn(properties, key) ? properties[key] : void 0;
344
345
  if (additionalProperties === false) {
345
- const extra = Object.keys(value).filter((key) => properties?.[key] === void 0);
346
+ const extra = Object.keys(value).filter((key) => declared(key) === void 0);
346
347
  if (extra.length > 0) return [`${path}: fields outside the schema: ${extra.join(", ")}`];
347
348
  }
348
- for (const key of required ?? []) if (!(key in value) || value[key] === void 0) return [`${path}: missing required field ${key}`];
349
+ for (const key of required ?? []) if (!Object.hasOwn(value, key) || value[key] === void 0) return [`${path}: missing required field ${key}`];
349
350
  const violations = [];
350
351
  for (const key of Object.keys(value)) {
351
- const child = properties?.[key];
352
+ const child = declared(key);
352
353
  if (child !== void 0) violations.push(...walk(child, value[key], `${path}.${key}`));
353
354
  }
354
355
  return violations;
@@ -360,42 +361,6 @@ function arrayViolations(schema, value, path, walk) {
360
361
  if (items === void 0) return [];
361
362
  return value.flatMap((item, index) => walk(items, item, `${path}[${index}]`));
362
363
  }
363
- /** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
364
- * 恰好一支命中,多支/零支都算违规。 */
365
- function violationsOf(schema, value, path) {
366
- if (schema.oneOf !== void 0) {
367
- const passing = schema.oneOf.filter((branch) => violationsOf(branch, value, path).length === 0);
368
- if (passing.length !== 1) return [`${path}: matched ${passing.length} of ${schema.oneOf.length} oneOf branches`];
369
- return [];
370
- }
371
- const { type } = schema;
372
- if (type === "object") return objectViolations(schema, value, path, violationsOf);
373
- if (type === "array") return arrayViolations(schema, value, path, violationsOf);
374
- if (type === void 0) return [];
375
- if (scalarMismatch(type, value)) return [`${path}: expected ${type}`];
376
- if (schema.const !== void 0 && value !== schema.const) return [`${path}: must be exactly ${JSON.stringify(schema.const)}`];
377
- if (schema.enum !== void 0 && !schema.enum.includes(value)) return [`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`];
378
- return [];
379
- }
380
- /**
381
- * 校验并放行工具返回值:必须命中该工具自己的 output schema。
382
- * 违规抛错而非静默裁剪——静默修正是第二个 bug 的开始,抛错让回归在开发期
383
- * (vitest/首次调用)就炸出来。
384
- * @param schema 该工具注册时声明的 output.schema(与宿主校验用的是同一个对象)
385
- * @param value execute 交回的 canonical 值(结构化对象 / 数组 / 标量 / null)
386
- * @param toolName 工具名(违规信息要点名,模型才知道该重试哪一次调用)
387
- * @param messages 双语文案
388
- * @returns 原值(校验只读不改,值原样交给宿主)
389
- */
390
- function assertToolOutput(schema, value, toolName, messages) {
391
- assertGuardable(schema, toolName, messages);
392
- const violations = violationsOf(schema, value, "result");
393
- if (violations.length > 0) throw new Error(format(messages.resultSchemaViolation, {
394
- tool: toolName,
395
- violations: violations.join("; ")
396
- }));
397
- return value;
398
- }
399
364
  /** provider key 形状:`sk-…`/`ark-…`/`ghp-…` 一类「短前缀 + 长随机串」。
400
365
  *
401
366
  * 为什么额外要求随机串里至少有一个数字或大写字母:真实 key 是随机 base62(32 位
@@ -403,14 +368,19 @@ function assertToolOutput(schema, value, toolName, messages) {
403
368
  * (`src/my-super-long-directory-name/index.ts`)。少了这道约束,session/delegate
404
369
  * 透传出的路径会被抹成 ***REDACTED***,模型拿不到可行动的位置信息。 */
405
370
  const STANDALONE_KEY = /\b[A-Za-z]{2,5}-(?=[A-Za-z0-9_-]{20,}\b)(?=[A-Za-z0-9_-]*[0-9A-Z])[A-Za-z0-9_-]{20,}\b/gu;
406
- /** `Authorization: Bearer xxx` / `bearer xxx`。
407
- *
408
- * 字符集写 `[a-z…]` 而不是 `[A-Za-z…]`:本条带 `i` 旗标,`a-z` 在大小写不敏感下
409
- * 已同时匹配 `A-Z`——再并列一枚 `A-Z` 是同一个字符集抄两遍(sonarjs 判为字符类内重复),
410
- * 删掉重复的那一位,匹配语言不变。 */
411
- const BEARER_VALUE = /(?<scheme>\bbearer\s+)[a-z0-9._~+/=-]{12,}/giu;
412
- /** `api_key: "xxx"`、`apiKey=xxx`、`token: xxx`、`x-api-key: xxx` 等赋值形态。 */
413
- const KEY_ASSIGNMENT = /(?<name>\b(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)[^\s"',;)}\]]{6,}/giu;
371
+ /** `Authorization: <scheme> <value>` / `Proxy-Authorization: …`:认**头名**而不是
372
+ * 枚举认证方案。头名本身就是「后面跟着凭据」的信号,Basic / Token / Digest 乃至
373
+ * vendor 自定义 scheme 都不会漏;而只认头名还避开了正文里「token based
374
+ * authentication」这类措辞被误抹。scheme 组连同其后的空白一起捕获,回填时凭据被
375
+ * 替换而 scheme 词保留。 */
376
+ const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
377
+ /** 裸 scheme + 值(没有头名时 OCR 也会整行回显)。scheme 只留 bearer:它在散文中几乎
378
+ * 不出现,而 token / basic 之类会撞上「token count」「basic usage」等普通措辞。 */
379
+ const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
380
+ /** `api_key: "xxx"`、`apiKey=xxx`、`token: xxx`、`x-api-key: xxx` 等赋值形态。
381
+ * 值下限取 4 且容许一个 scheme 词:`token: Basic <base64>` 里赋值形态先能吃到的只是
382
+ * scheme 词(Basic 恰好 5 字符),下限更高时整条失配,后面那个 base64 就原样进了日志。 */
383
+ const KEY_ASSIGNMENT = /(?<name>\b(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
414
384
  const REDACTION = "***REDACTED***";
415
385
  /**
416
386
  * 抹掉文本里 key 形状的片段。
@@ -425,7 +395,49 @@ const REDACTION = "***REDACTED***";
425
395
  * 要求随机串特征,避免把要交给模型的文件路径一起抹掉。
426
396
  */
427
397
  function redactKeyMaterial(text) {
428
- return text.replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>${REDACTION}`).replaceAll(STANDALONE_KEY, REDACTION);
398
+ return text.replaceAll(AUTH_HEADER_VALUE, `$<name>$<scheme>${REDACTION}`).replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>$<scheme>${REDACTION}`).replaceAll(STANDALONE_KEY, REDACTION);
399
+ }
400
+ /** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
401
+ * 恰好一支命中,多支/零支都算违规。 */
402
+ function violationsOf(schema, value, path) {
403
+ const violations = [];
404
+ const { oneOf } = schema;
405
+ if (oneOf !== void 0) {
406
+ const passing = oneOf.filter((branch) => violationsOf(branch, value, path).length === 0);
407
+ if (passing.length !== 1) violations.push(`${path}: matched ${passing.length} of ${oneOf.length} oneOf branches`);
408
+ }
409
+ const { type } = schema;
410
+ if (type === "object") violations.push(...objectViolations(schema, value, path, violationsOf));
411
+ else if (type === "array") violations.push(...arrayViolations(schema, value, path, violationsOf));
412
+ else if (type !== void 0 && scalarMismatch(type, value)) violations.push(`${path}: expected ${type}`);
413
+ if (schema.const !== void 0 && value !== schema.const) violations.push(`${path}: must be exactly ${JSON.stringify(schema.const)}`);
414
+ if (schema.enum !== void 0 && !schema.enum.includes(value)) violations.push(`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`);
415
+ return violations;
416
+ }
417
+ /**
418
+ * 校验并放行工具返回值:必须命中该工具自己的 output schema。
419
+ * 违规抛错而非静默裁剪——静默修正是第二个 bug 的开始,抛错让回归在开发期
420
+ * (vitest/首次调用)就炸出来。
421
+ * @param schema 该工具注册时声明的 output.schema(与宿主校验用的是同一个对象)
422
+ * @param value execute 交回的 canonical 值(结构化对象 / 数组 / 标量 / null)
423
+ * @param toolName 工具名(违规信息要点名,模型才知道该重试哪一次调用)
424
+ * @param messages 双语文案
425
+ * @returns 原值(校验只读不改,值原样交给宿主)
426
+ */
427
+ function assertToolOutput(schema, value, toolName, messages) {
428
+ assertGuardable(schema, toolName, messages);
429
+ const violations = violationsOf(schema, value, "result");
430
+ if (violations.length > 0) {
431
+ const MAX_LISTED = 20;
432
+ const MAX_CHARS = 2e3;
433
+ const omitted = violations.length - MAX_LISTED;
434
+ const listed = violations.slice(0, MAX_LISTED).join("; ") + (omitted > 0 ? ` …(+${omitted})` : "");
435
+ throw new Error(format(messages.resultSchemaViolation, {
436
+ tool: toolName,
437
+ violations: redactKeyMaterial(listed).slice(0, MAX_CHARS)
438
+ }));
439
+ }
440
+ return value;
429
441
  }
430
442
  //#endregion
431
443
  //#region lib/argv-guard.ts
@@ -563,19 +575,23 @@ function idValue(value, name, messages) {
563
575
  * 负数/非整数由 OCR 侧报错,但桥接层先做同源校验给出更早的错误)。
564
576
  * value 为 undefined/null 时省略 flag(跟随 OCR flag 默认值)。
565
577
  *
566
- * 注入面:数值经 `String(num)` 落进命令串(不走 shq)。Number.isInteger 对
567
- * 1e21 为 true 而 String() 会写成 `1e+21`,故再加一道「只含数字」的形态校验:
568
- * 命令串里的数值 token 结构上不可能带 shell 元字符。
578
+ * 注入面:数值经 `String(num)` 落进命令串(不走 shq)。数值 token 结构上不可能带
579
+ * shell 元字符,「只含数字」的形态校验顺带把 1e21 写成 `1e+21` 这类形态也挡住。
569
580
  */
570
581
  function pushIntFlag(parts, flag, value, min, messages) {
571
582
  if (value === void 0 || value === null) return;
572
- const num = typeof value === "number" ? value : Number(value);
583
+ const reject = () => {
584
+ throw new Error(format(messages.intFlagMustBeInt, {
585
+ flag,
586
+ min: String(min),
587
+ received: describeValue(value)
588
+ }));
589
+ };
590
+ const decimalString = typeof value === "string" && /^\d+$/u.test(value.trim());
591
+ if (typeof value !== "number" && !decimalString) reject();
592
+ const num = Number(value);
573
593
  const text = String(num);
574
- if (!Number.isInteger(num) || num < min || !/^\d+$/u.test(text)) throw new Error(format(messages.intFlagMustBeInt, {
575
- flag,
576
- min: String(min),
577
- received: describeValue(value)
578
- }));
594
+ if (!Number.isSafeInteger(num) || num < min || !/^\d+$/u.test(text)) reject();
579
595
  parts.push(flag, text);
580
596
  }
581
597
  /** 模式参数解析:workspace(默认)/commit/branch 互斥,非法组合与错类型抛错。
@@ -983,14 +999,15 @@ function str(value) {
983
999
  * 故作兜底值。
984
1000
  */
985
1001
  function nonNegativeInt(value) {
986
- if (!Number.isFinite(value) || value < 0) return 0;
987
- return Math.trunc(value);
1002
+ const truncated = Math.trunc(value);
1003
+ if (!Number.isSafeInteger(truncated) || truncated < 0) return 0;
1004
+ return truncated;
988
1005
  }
989
1006
  function num(value) {
990
1007
  if (typeof value === "number") return nonNegativeInt(value);
991
1008
  if (typeof value === "string") {
992
1009
  const trimmed = value.trim();
993
- if (trimmed === "") return 0;
1010
+ if (!/^\d+$/u.test(trimmed)) return 0;
994
1011
  return nonNegativeInt(Number(trimmed));
995
1012
  }
996
1013
  return 0;
@@ -1153,6 +1170,42 @@ function delegatePreviewFailure() {
1153
1170
  mergeBase: ""
1154
1171
  };
1155
1172
  }
1173
+ /**
1174
+ * preview 的条目数组 → 结构化清单;返回 null 表示「上游给了条目却一条都没解析出来」。
1175
+ *
1176
+ * 判据分两层:部分条目解析不出来照旧丢弃(OCR 偶发脏条目不该毁掉整份清单),
1177
+ * 但一条不剩时不能继续以 ok:true 的形态报「0 个文件可审」——那不是「没有可审的
1178
+ * 文件」,是半截产出,模型与用户都会拿它当范围结论。
1179
+ */
1180
+ function toDelegateFiles(raw) {
1181
+ if (!Array.isArray(raw)) return [];
1182
+ const files = raw.map((entry) => {
1183
+ if (!isRecord(entry)) return null;
1184
+ const entryPath = str(entry["path"]);
1185
+ if (entryPath === "") return null;
1186
+ return {
1187
+ path: entryPath,
1188
+ status: str(entry["status"]),
1189
+ insertions: num(entry["insertions"]),
1190
+ deletions: num(entry["deletions"])
1191
+ };
1192
+ }).filter((fileEntry) => fileEntry !== null);
1193
+ return raw.length > 0 && files.length === 0 ? null : files;
1194
+ }
1195
+ /** excluded_files 同款门:条目形状与 reviewable 一致,缺 path 即不可用。 */
1196
+ function toExcludedFiles(raw) {
1197
+ if (!Array.isArray(raw)) return [];
1198
+ const files = raw.map((entry) => {
1199
+ if (!isRecord(entry)) return null;
1200
+ const entryPath = str(entry["path"]);
1201
+ if (entryPath === "") return null;
1202
+ return {
1203
+ path: entryPath,
1204
+ reason: str(entry["reason"])
1205
+ };
1206
+ }).filter((fileEntry) => fileEntry !== null);
1207
+ return raw.length > 0 && files.length === 0 ? null : files;
1208
+ }
1156
1209
  /** 解析 `ocr delegate preview --format json` 输出。 */
1157
1210
  function parseDelegatePreview(text) {
1158
1211
  let doc;
@@ -1168,22 +1221,9 @@ function parseDelegatePreview(text) {
1168
1221
  const value = root[key];
1169
1222
  if (value !== void 0 && !Array.isArray(value)) return delegatePreviewFailure();
1170
1223
  }
1171
- const reviewable = Array.isArray(root["reviewable_files"]) ? root["reviewable_files"].map((fileRaw) => {
1172
- if (!isRecord(fileRaw)) return null;
1173
- return {
1174
- path: str(fileRaw["path"]),
1175
- status: str(fileRaw["status"]),
1176
- insertions: num(fileRaw["insertions"]),
1177
- deletions: num(fileRaw["deletions"])
1178
- };
1179
- }).filter((fileEntry) => fileEntry !== null && fileEntry.path.length > 0) : [];
1180
- const excluded = Array.isArray(root["excluded_files"]) ? root["excluded_files"].map((fileRaw) => {
1181
- if (!isRecord(fileRaw)) return null;
1182
- return {
1183
- path: str(fileRaw["path"]),
1184
- reason: str(fileRaw["reason"])
1185
- };
1186
- }).filter((fileEntry) => fileEntry !== null && fileEntry.path.length > 0) : [];
1224
+ const reviewable = toDelegateFiles(root["reviewable_files"]);
1225
+ const excluded = toExcludedFiles(root["excluded_files"]);
1226
+ if (reviewable === null || excluded === null) return delegatePreviewFailure();
1187
1227
  return {
1188
1228
  ok: true,
1189
1229
  mode: str(root["mode"]),
@@ -1223,15 +1263,20 @@ function parseDelegateRules(text) {
1223
1263
  ok: false,
1224
1264
  groups: []
1225
1265
  };
1266
+ const groups = groupsRaw.filter((groupRaw) => isRecord(groupRaw) && Array.isArray(groupRaw["files"])).map((groupRaw) => ({
1267
+ groupId: num(groupRaw["group_id"]),
1268
+ source: str(groupRaw["source"]),
1269
+ pattern: str(groupRaw["pattern"]),
1270
+ files: groupRaw.files.filter((file) => typeof file === "string" && file.trim() !== "").map((file) => file.trim()),
1271
+ rule: str(groupRaw["rule"])
1272
+ })).filter((group) => group.files.length > 0);
1273
+ if (groupsRaw.length > 0 && groups.length === 0) return {
1274
+ ok: false,
1275
+ groups: []
1276
+ };
1226
1277
  return {
1227
1278
  ok: true,
1228
- groups: groupsRaw.filter((groupRaw) => isRecord(groupRaw) && Array.isArray(groupRaw["files"])).map((groupRaw) => ({
1229
- groupId: num(groupRaw["group_id"]),
1230
- source: str(groupRaw["source"]),
1231
- pattern: str(groupRaw["pattern"]),
1232
- files: groupRaw.files.map((file) => str(file)).filter(Boolean),
1233
- rule: str(groupRaw["rule"])
1234
- })).filter((group) => group.files.length > 0)
1279
+ groups
1235
1280
  };
1236
1281
  }
1237
1282
  //#endregion
@@ -1795,6 +1840,7 @@ async function providersForCard(gateway, paths, messages) {
1795
1840
  */
1796
1841
  async function applyProviderSelection(gateway, paths, selection, messages) {
1797
1842
  const { provider, model } = selection;
1843
+ if (typeof provider !== "string" || typeof model !== "string" || provider.trim() === "" || model.trim() === "") throw new Error(format(messages.providerModelRequired, {}));
1798
1844
  const { providers, source } = readProviders(gateway, messages);
1799
1845
  if (source.status !== "ready") throw new Error(source.message);
1800
1846
  const target = providers.find((providerCfg) => providerCfg.name === provider);
@@ -1834,11 +1880,11 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
1834
1880
  * api_key_cmd 并删除明文,返回 'migrated';找不到 envKey 或构造命令失败 →
1835
1881
  * 'unknown';无明文 → 'noPlain'(不动作)。
1836
1882
  */
1837
- function migrateEntry(entry, envKey, scriptPath, dshHome, messages) {
1883
+ function migrateEntry(entry, envKey, context) {
1838
1884
  if (typeof entry["api_key"] !== "string" || entry["api_key"].length === 0) return "noPlain";
1839
- if (envKey === void 0 || envKey === "") return "unknown";
1885
+ if (envKey === void 0 || envKey === "" || !context.resolvableEnvKeys.has(envKey)) return "unknown";
1840
1886
  try {
1841
- entry["api_key_cmd"] = buildApiKeyCmd(scriptPath, envKey, dshHome, messages);
1887
+ entry["api_key_cmd"] = buildApiKeyCmd(context.scriptPath, envKey, context.dshHome, context.messages);
1842
1888
  } catch {
1843
1889
  return "unknown";
1844
1890
  }
@@ -1865,13 +1911,18 @@ async function migratePlaintextKeys(gateway, paths, messages) {
1865
1911
  if (source.status !== "ready" && source.status !== "credentials-unavailable") throw new Error(source.message);
1866
1912
  parseExistingConfig(precheck, paths.ocrConfigJson, messages);
1867
1913
  const scriptPath = credentialScriptPath(paths);
1868
- if (!existsSync(scriptPath)) return {
1869
- migrated: [],
1870
- skippedUnknown: []
1871
- };
1914
+ if (!existsSync(scriptPath)) throw new Error(format(messages.credScriptMissing, { path: scriptPath }));
1872
1915
  const dshHome = resolveDshHome();
1873
1916
  const envByProvider = /* @__PURE__ */ new Map();
1874
1917
  for (const providerCfg of providers) if (providerCfg.apiKeyEnv !== null && providerCfg.apiKeyEnv !== "") envByProvider.set(providerCfg.name, providerCfg.apiKeyEnv);
1918
+ const candidateEnvKeys = [...new Set(envByProvider.values())];
1919
+ const resolvedFlags = await Promise.all(candidateEnvKeys.map((envKey) => gateway.credentialResolvable(envKey)));
1920
+ const migrateContext = {
1921
+ scriptPath,
1922
+ dshHome,
1923
+ resolvableEnvKeys: new Set(candidateEnvKeys.filter((_envKey, index) => resolvedFlags[index] === true)),
1924
+ messages
1925
+ };
1875
1926
  return await updateConfigLocked(paths.ocrConfigJson, async () => {
1876
1927
  const doc = parseExistingConfig(readText(paths.ocrConfigJson), paths.ocrConfigJson, messages);
1877
1928
  const customsRaw = fieldOf(doc, "custom_providers");
@@ -1883,7 +1934,7 @@ async function migratePlaintextKeys(gateway, paths, messages) {
1883
1934
  const migrated = [];
1884
1935
  const skippedUnknown = [];
1885
1936
  for (const [name, raw] of Object.entries(customs)) if (isRecord(raw)) {
1886
- const result = migrateEntry(raw, envByProvider.get(name), scriptPath, dshHome, messages);
1937
+ const result = migrateEntry(raw, envByProvider.get(name), migrateContext);
1887
1938
  if (result === "migrated") migrated.push(name);
1888
1939
  else if (result === "unknown") skippedUnknown.push(name);
1889
1940
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jayyuen66/dsh-ocr-review",
3
- "version": "0.1.4-rc",
3
+ "version": "0.1.4-rc.1",
4
4
  "description": "dsh 工作区代码审查插件:把 open-code-review(ocr CLI)封装为 5 个模型工具 + 设置卡片(从 settings.yaml 选 provider/model 应用到 OCR 配置)。",
5
5
  "license": "MIT",
6
6
  "author": "JayYuen",
@@ -66,16 +66,28 @@ function isRecord(value) {
66
66
 
67
67
  /**
68
68
  * 数据目录:显式参数 > $DSH_HOME > homedir()/.dsh(空/全空白按未设置处理)。
69
+ *
70
+ * 相对路径在这里是硬错误而不是照用:本脚本的 cwd 是 OCR 当时所在的那个仓库,
71
+ * 相对路径会被解析成 `<被审查仓库>/.dsh/…`——正是文件头声明要避免的「拿被审查
72
+ * 仓库里的文件当用户凭据」。猜不出用户指的是哪个目录,就不猜。
69
73
  * @param {string | undefined} fromArg
70
74
  * @returns {string}
71
75
  */
72
76
  function dataDirFrom(fromArg) {
73
77
  const arg = typeof fromArg === "string" ? fromArg.trim() : "";
78
+ const fromEnv = typeof process.env.DSH_HOME === "string" ? process.env.DSH_HOME.trim() : "";
79
+ let chosen = path.join(homedir(), ".dsh");
80
+ if (fromEnv !== "") {
81
+ chosen = fromEnv;
82
+ }
74
83
  if (arg !== "") {
75
- return arg;
84
+ chosen = arg;
76
85
  }
77
- const fromEnv = typeof process.env.DSH_HOME === "string" ? process.env.DSH_HOME.trim() : "";
78
- return fromEnv === "" ? path.join(homedir(), ".dsh") : fromEnv;
86
+ if (!path.isAbsolute(chosen)) {
87
+ process.stderr.write(`[get-cred] dsh 数据目录必须是绝对路径,收到 ${JSON.stringify(chosen)}\n`);
88
+ process.exit(1);
89
+ }
90
+ return chosen;
79
91
  }
80
92
 
81
93
  /** 取值过程中遇到的坏消息(文件损坏/权限);只在最终失败时一并报出,
@@ -113,17 +125,29 @@ function isNotFound(error) {
113
125
  */
114
126
  function yamlErrorDetail(error) {
115
127
  if (error instanceof Error) {
116
- // js-yaml 的 YAMLException 覆盖了 toString(compact):传 true 走紧凑分支。
117
- // 别的 Error 会忽略这个参数,返回普通的 "Name: message",同样不含 snippet。
128
+ // js-yaml 的 YAMLException 覆盖了 toString(compact):传 true 走紧凑分支,
129
+ // 只有这一条路不带 snippet。别的 Error 会忽略这个参数,返回普通的
130
+ // "Name: message",同样不含出错点邻域的原文。
118
131
  const compact = error.toString(true);
119
132
  if (typeof compact === "string" && compact !== "") {
120
133
  return compact;
121
134
  }
122
- return error.message;
135
+ // 拿不到紧凑形态时**不能**退回 error.message:js-yaml 构造器里赋的就是
136
+ // this.message = formatError(this, false),那份 message 正是带 snippet 的
137
+ // 那一条。改用 reason + 行列定位自行拼装,两者都不含原文。
138
+ const reason = typeof error.reason === "string" ? error.reason : "";
139
+ const { mark } = error;
140
+ const line = isRecord(mark) ? mark.line : undefined;
141
+ const column = isRecord(mark) ? mark.column : undefined;
142
+ const located =
143
+ typeof line === "number" && typeof column === "number"
144
+ ? ` (line:${line + 1}, column:${column + 1})`
145
+ : "";
146
+ return `${reason}${located}`;
123
147
  }
124
- // 兜底也不能用 String(error):error 是 unknown,非字符串时会被隐式走
125
- // Object.prototype.toString,产出 "[object Object]" 这种毫无信息量的文本。
126
- return error instanceof Error ? error.message : "";
148
+ // 到这里 error 不是 Error 实例:String(error) 会隐式走 Object.prototype.toString
149
+ // 产出 "[object Object]" 这种毫无信息量的文本,直接给空串。
150
+ return "";
127
151
  }
128
152
 
129
153
  function fromCredentialsDoc(credPath, refName) {
@@ -146,8 +170,21 @@ function fromCredentialsDoc(credPath, refName) {
146
170
  }
147
171
  }
148
172
  const refs = isRecord(doc) ? doc.refs : undefined;
149
- const value = isRecord(refs) ? refs[refName] : undefined;
150
- return typeof value === "string" && value.length > 0 ? value : undefined;
173
+ let value = isRecord(refs) ? refs[refName] : undefined;
174
+ if (value !== undefined && typeof value !== "string") {
175
+ // 只报形状,绝不把值本身写进 problems(明文不落日志)。refs.<REF> 被写成
176
+ // 数字/布尔/映射时若静默当「没有值」,用户改了托管文档却拿到下一层的旧值,
177
+ // 全程零提示。
178
+ problems.push(
179
+ `凭证文件 refs.${refName} 不是字符串(是 ${Array.isArray(value) ? "数组" : typeof value}):${credPath}`,
180
+ );
181
+ value = undefined;
182
+ }
183
+ // OCR 的 api_key_cmd 契约只认**单行** stdout(空/多行都是硬错误)。托管文档里
184
+ // 引号内的空白与块标量(| / >)分别产出空白值与多行值:交给 OCR 只会换来隔了
185
+ // 一层的「resolve LLM endpoint」报错,而这一层本可以当没有值、继续往下回退。
186
+ const key = typeof value === "string" ? value.trim() : "";
187
+ return key !== "" && !/[\r\n]/u.test(key) ? key : undefined;
151
188
  }
152
189
 
153
190
  /**
@@ -157,8 +194,13 @@ function fromCredentialsDoc(credPath, refName) {
157
194
  */
158
195
  function unquotedValue(raw) {
159
196
  const trimmed = raw.trim();
160
- const quoted = trimmed.length > 1 && (trimmed.startsWith('"') || trimmed.startsWith("'"));
161
- return quoted ? trimmed.slice(1, -1) : trimmed;
197
+ const [first] = trimmed;
198
+ // 只有首尾**配对**的引号才剥。单边引号是用户写坏了,硬砍首尾等于把凭据改短
199
+ // 一位,拿一个错 key 去打上游(401),比报「未配置」难排查得多。
200
+ if (trimmed.length > 1 && (first === '"' || first === "'") && trimmed.endsWith(first)) {
201
+ return trimmed.slice(1, -1);
202
+ }
203
+ return trimmed;
162
204
  }
163
205
 
164
206
  /**
@@ -178,7 +220,10 @@ function fromUserDotEnv(envPath, refName) {
178
220
  }
179
221
  const pattern = new RegExp(`^\\s*(?:export\\s+)?${refName}\\s*=\\s*(?<value>.*)$`, "u");
180
222
  let hit = "";
181
- for (const line of (text ?? "").split("\n")) {
223
+ // 按 /\r?\n/ 拆而不是 "\n":上面那条正则的 `.*` 不匹配 \r、`$` 又要求串尾,
224
+ // 而按 "\n" 拆出来的 CRLF 行每行都带尾随 \r,于是每一行都匹配不上——整个 .env
225
+ // 层对所有 key 静默失配,用户只看到隔了一层的「取不到凭据」。
226
+ for (const line of (text ?? "").split(/\r?\n/u)) {
182
227
  const value = unquotedValue(pattern.exec(line)?.groups?.value ?? "");
183
228
  if (value.length > 0) {
184
229
  hit = value;