@leonhardthust/dsh-decision-engine 0.0.0-stage → 1.9.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -2
  3. package/README.zh.md +57 -0
  4. package/config.example.yml +436 -0
  5. package/cordis.patch.yml +14 -0
  6. package/docs/assets/config-page.png +0 -0
  7. package/docs/assets/decisions-tab.png +0 -0
  8. package/docs/assets/hero.jpeg +0 -0
  9. package/docs/assets/stats-charts-preview.png +0 -0
  10. package/docs/assets/stats-donut-toggle.png +0 -0
  11. package/docs/assets/stats-interactive-charts.png +0 -0
  12. package/docs/assets/stats-point-donut.png +0 -0
  13. package/docs/assets/stats-ranking-bars.png +0 -0
  14. package/docs/auto-review-hotfix.md +33 -0
  15. package/docs/lessons/cloudflare-builds-root-directory.md +51 -0
  16. package/docs/lessons/cold-start-timeout.md +134 -0
  17. package/docs/lessons/endpoint-path-vs-model.md +26 -0
  18. package/docs/lessons/model-field-required-by-provider.md +31 -0
  19. package/docs/local-dev-link.md +54 -0
  20. package/docs/releasing.md +69 -0
  21. package/docs/security-revision.md +130 -0
  22. package/icon.svg +6 -0
  23. package/lib/adapters/agent.js +314 -0
  24. package/lib/adapters/route.js +144 -0
  25. package/lib/adapters/tools.js +186 -0
  26. package/lib/adapters/translate.js +143 -0
  27. package/lib/approval-detail.js +325 -0
  28. package/lib/client.charts.js +36 -0
  29. package/lib/client.js +8715 -0
  30. package/lib/config-file.js +136 -0
  31. package/lib/credentials.js +187 -0
  32. package/lib/decision-cache.js +188 -0
  33. package/lib/decision-points.js +205 -0
  34. package/lib/decision-store.js +774 -0
  35. package/lib/endpoint-fallback.js +143 -0
  36. package/lib/engine.js +312 -0
  37. package/lib/guard/advisory.js +100 -0
  38. package/lib/guard/audit.js +81 -0
  39. package/lib/guard/context.js +128 -0
  40. package/lib/guard/effects.js +104 -0
  41. package/lib/guard/fallback.js +139 -0
  42. package/lib/guard/listener.js +214 -0
  43. package/lib/guard/messages.js +31 -0
  44. package/lib/guard/policy.js +28 -0
  45. package/lib/home-paths.js +86 -0
  46. package/lib/index.js +3823 -0
  47. package/lib/outcomes.js +169 -0
  48. package/lib/points/spec.js +645 -0
  49. package/lib/providers.js +958 -0
  50. package/lib/resilience.js +308 -0
  51. package/lib/scope.js +67 -0
  52. package/lib/server-config.js +351 -0
  53. package/lib/session-events.js +75 -0
  54. package/lib/stats-store.js +916 -0
  55. package/lib/stats.js +1293 -0
  56. package/lib/telemetry.js +465 -0
  57. package/lib/tokenizer.js +201 -0
  58. package/lib/tolerance.js +498 -0
  59. package/lib/vendor/microcharts-charts.css +47 -0
  60. package/lib/vendor-src/entry.js +13 -0
  61. package/lib/vendor-src/jsx-shim.js +34 -0
  62. package/locale/en.json +6 -0
  63. package/locale/zh.json +6 -0
  64. package/package.json +101 -3
  65. package/scripts/migrate-profile.mjs +102 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * 韧性策略:哪些失败值得重试、冷却如何退避、失败如何翻译给用户看。
3
+ *
4
+ * 为什么单独成一个模块
5
+ * --------------------
6
+ * 这个插件把每一次工具调用先发给远端「决策小模型」判断,决策端点是外部服务
7
+ * (例如 https://api.bamboonode.dev/v1/decisions)。外部服务会超时、会断链、
8
+ * 会自己报 500,而这些失败**不能**让主流程停摆——所以每次失败最终都会降级
9
+ * (fail-open)。降级本身是对的,问题是降级之后用户只看到一句「决策失败」,
10
+ * 完全不知道自己该改什么。
11
+ *
12
+ * 生产遥测里 358 次降级的构成(2026 年,真实数据):
13
+ *
14
+ * - timeout 83 次 卡在 8000ms 预算线上
15
+ * - network-error 84 次 fetch failed / ECONNRESET,多在 5004–5557ms 后失败
16
+ * - http-500 95 次 服务端自己报 Database error / Task request failed
17
+ * - cooldown 93 次 连续 3 次失败后 60s 内全部快速失败,是前几类的放大
18
+ *
19
+ * 关键实测规律(4 组独立实验一致):服务端冷启动时首发要 7–16 秒;但**首发
20
+ * 即使被超时掐断,也已经把服务端焐热了**,紧接着的第二发 1.1–1.7 秒必中。
21
+ * 所以对 timeout / network-error 重试一次收益极大;而 4xx/5xx 重试毫无意义
22
+ * (4xx 是配置错,5xx 是服务端故障,重试只是多烧额度)。本模块把这条规律
23
+ * 固化成三个纯函数,让调用方不必各自记一遍。
24
+ *
25
+ * 为什么独立存在
26
+ * --------------
27
+ * 这里只有纯函数:不 import 任何东西、不读环境变量、不发网络请求、不读时钟
28
+ * (时间由调用方传入,连 `Date.now()` 都没有)。因此它可以被裸 Node 直接
29
+ * 单测(见 test/resilience.test.js),不需要拉起插件运行时、不需要 node_modules。
30
+ *
31
+ * @module dsh-decision-engine/resilience
32
+ */
33
+
34
+ /**
35
+ * 值得重试的失败原因(冻结,防止调用方就地改写)。
36
+ *
37
+ * 只包含两类「重试真能变好」的失败:
38
+ * - `timeout`:冷启动首发被掐断时,服务端已经被唤醒,第二发通常 1–2 秒返回。
39
+ * - `network-error`:连接层的瞬时抖动(ECONNRESET / fetch failed)重发一次
40
+ * 常常就通了。
41
+ *
42
+ * 刻意**不包含** http-5xx:那是服务商侧故障,重试只会多烧一次额度;也不包含
43
+ * 任何 4xx:地址、模型名、密钥配错了,重试一千次还是同样的错。
44
+ *
45
+ * @type {ReadonlyArray<string>}
46
+ */
47
+ export const RETRYABLE_REASONS = Object.freeze(["timeout", "network-error"]);
48
+
49
+ /**
50
+ * 判断一个降级原因是否值得重试。
51
+ *
52
+ * 只做精确匹配,不做大小写归一:reason 是插件内部生成的审计字段(`timeout`、
53
+ * `http-500` …),大小写写错说明调用方拿错了字段,静默纠正反而会掩盖 bug。
54
+ *
55
+ * @param {unknown} reason - 降级原因(通常来自遥测的 reason 字段)。
56
+ * @returns {boolean} 是否属于 {@link RETRYABLE_REASONS}。
57
+ */
58
+ export function isRetryableReason(reason) {
59
+ return typeof reason === "string" && RETRYABLE_REASONS.includes(reason);
60
+ }
61
+
62
+ /**
63
+ * 判断「刚失败的那一次」之后还要不要再打一次。
64
+ *
65
+ * `attempt` 是**刚失败那次**的 0 基序号(第一次请求 attempt=0),因此重试条件
66
+ * 写成 `attempt + 1 < maxAttempts`:maxAttempts 是总尝试次数上限,含首发。
67
+ *
68
+ * 预算判断用 `elapsedMs < maxBudgetMs`(严格小于):预算已经用满时再发一次,
69
+ * 只会在调用方等不到结果之后才失败,等于把一次失败拖成一次更慢的失败。注意
70
+ * 这里看的是**整轮已耗时**,不是单次 timeout——单次超时预算再大,整轮预算也
71
+ * 可能已经耗尽。
72
+ *
73
+ * 参数缺失或类型不对一律返回 false,绝不抛错:这个函数在失败路径上被调用,
74
+ * 它自己再抛异常会把「降级」变成「崩溃」,比不重试严重得多。
75
+ *
76
+ * @param {object} options - 判断所需的全部输入(全部由调用方传入,本函数不读时钟)。
77
+ * @param {string} options.reason - 刚失败的降级原因。
78
+ * @param {number} options.attempt - 刚失败那次的 0 基序号。
79
+ * @param {number} options.elapsedMs - 整轮已耗时(毫秒)。
80
+ * @param {number} [options.timeoutMs] - 单次请求的超时预算;仅为让调用方把同一次
81
+ * 请求的时序参数原样传进来而保留,**不参与判断**(见上:重试看的是整轮预算)。
82
+ * @param {number} options.maxAttempts - 总尝试次数上限(含首发)。
83
+ * @param {number} options.maxBudgetMs - 整轮时间预算(毫秒)。
84
+ * @returns {boolean} true 表示应该再打一次。
85
+ */
86
+ export function shouldRetry(options) {
87
+ try {
88
+ if (options === null || typeof options !== "object") return false;
89
+ const { reason, attempt, elapsedMs, maxAttempts, maxBudgetMs } = options;
90
+ if (!isRetryableReason(reason)) return false;
91
+ // attempt 必须是 0 基非负整数:负数或小数说明调用方把序号算错了。
92
+ if (!Number.isInteger(attempt) || attempt < 0) return false;
93
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) return false;
94
+ if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return false;
95
+ if (!Number.isFinite(maxBudgetMs) || maxBudgetMs <= 0) return false;
96
+ // 已经没有下一次了。
97
+ if (attempt + 1 >= maxAttempts) return false;
98
+ // 预算已经用完(等于也算用完):再发一次只会更慢地失败。
99
+ if (elapsedMs >= maxBudgetMs) return false;
100
+ return true;
101
+ } catch {
102
+ // 例如调用方传了带 getter 的怪对象:失败路径上宁可放弃重试,也不能抛错。
103
+ return false;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 计算第 `streak` 轮冷却应该等多久。
109
+ *
110
+ * 为什么不是固定 60s:遥测里 cooldown 有 93 次,是前三类失败的**放大**——
111
+ * 连续 3 次失败后进入 60s 冷却,这 60s 内的每一次决策都快速失败。固定时长
112
+ * 在服务端长时间故障时只会把「失败一次」放大成「失败一整分钟」。指数退避
113
+ * 让连续故障时等待逐轮变长,服务端恢复后又能很快回到正常节奏;封顶则避免
114
+ * 等待时间无限增长到用户以为插件坏了。
115
+ *
116
+ * 实现上**不加抖动**(no jitter):本模块是纯函数,同一输入必须得到同一输出,
117
+ * 否则没法单测,也没法在遥测里解释「这次为什么等了这么久」。
118
+ *
119
+ * 参数归一规则(契约里「非法输入返回 base」的落地方式):
120
+ * - `baseCooldownMs` 不是正有限数 → 返回 0(base 本身不可用,不能把 NaN 传出去)。
121
+ * - `streak` 不是有限数(undefined / NaN / 字符串…)→ 按 1 处理,即返回 base。
122
+ * - `streak <= 0` → 按 1 处理;小数向下取整。
123
+ * - `maxCooldownMs` 不是正有限数 → 不封顶(但仍不会返回 Infinity)。
124
+ *
125
+ * @param {object} options - 退避参数。
126
+ * @param {number} options.baseCooldownMs - 第一轮冷却时长(毫秒)。
127
+ * @param {number} options.streak - 连续进入冷却的轮次,从 1 起。
128
+ * @param {number} [options.maxCooldownMs] - 冷却时长上限(毫秒)。
129
+ * @returns {number} 本轮应冷却的毫秒数。
130
+ */
131
+ export function nextCooldownMs(options = {}) {
132
+ const { baseCooldownMs, streak, maxCooldownMs } = options ?? {};
133
+ const base =
134
+ typeof baseCooldownMs === "number" && Number.isFinite(baseCooldownMs) && baseCooldownMs > 0
135
+ ? baseCooldownMs
136
+ : 0;
137
+ if (base === 0) return 0;
138
+ const rounds = typeof streak === "number" && Number.isFinite(streak) ? Math.max(1, Math.floor(streak)) : 1;
139
+ const cap =
140
+ typeof maxCooldownMs === "number" && Number.isFinite(maxCooldownMs) && maxCooldownMs > 0
141
+ ? maxCooldownMs
142
+ : Number.MAX_SAFE_INTEGER;
143
+ let waitMs = base;
144
+ for (let round = 1; round < rounds; round += 1) {
145
+ if (waitMs >= cap) break;
146
+ waitMs *= 2;
147
+ }
148
+ return Math.min(waitMs, cap);
149
+ }
150
+
151
+ /**
152
+ * 从响应体或调用方给的字段里找服务商的请求 id。
153
+ *
154
+ * 为什么值得单独找:5xx 是服务商侧故障,本地配置改不了任何东西,**请求 id 是
155
+ * 用户唯一能拿给服务商定位问题的抓手**。各家名字不同(实测 bamboonode 走
156
+ * `x-oneapi-request-id`,SiliconFlow 走 `x-siliconcloud-trace-id`),所以这里
157
+ * 用一组常见键名去匹配,而不是写死一家。
158
+ *
159
+ * @param {object} extra - describeFailure 的第二参数。
160
+ * @returns {string} 找到的 id,找不到时为空串。
161
+ */
162
+ function pickRequestId(extra) {
163
+ const direct = typeof extra.requestId === "string" ? extra.requestId.trim() : "";
164
+ if (direct.length > 0) return direct;
165
+ const body = extra.body;
166
+ const text = typeof body === "string" ? body : body !== null && typeof body === "object" ? safeJson(body) : "";
167
+ if (text.length === 0) return "";
168
+ const pattern =
169
+ /(?:x-oneapi-request-id|x-siliconcloud-trace-id|request[_-]?id|trace[_-]?id)["']?\s*[:=]\s*["']?([A-Za-z0-9._:-]{4,})/i;
170
+ const match = pattern.exec(text);
171
+ return match === null ? "" : match[1];
172
+ }
173
+
174
+ /** 把对象安全地转成一行文本(循环引用或不可序列化时返回空串)。 */
175
+ function safeJson(value) {
176
+ try {
177
+ return JSON.stringify(value);
178
+ } catch {
179
+ return "";
180
+ }
181
+ }
182
+
183
+ /**
184
+ * 抹掉文本里的审计字段与换行。
185
+ *
186
+ * 遥测里的 `risk=` / `p=` 是内部审计字段,用户看到只会困惑;服务商返回的错误体
187
+ * 又常带换行,塞进一句 hint 会破坏排版。这里统一压成一行并去掉这两类字段。
188
+ *
189
+ * @param {unknown} value - 任意待展示文本。
190
+ * @returns {string} 清洗后的文本(可能为空串)。
191
+ */
192
+ function scrub(value) {
193
+ if (typeof value !== "string") return "";
194
+ return value
195
+ .replace(/\b(?:risk|p)\s*=\s*[^\s,;&}]+/gi, "")
196
+ .replace(/\s+/g, " ")
197
+ .trim();
198
+ }
199
+
200
+ /**
201
+ * 截取响应体摘要,供 5xx 的 hint 使用。
202
+ *
203
+ * @param {unknown} body - 服务商响应体(字符串或对象)。
204
+ * @returns {string} 最长 300 字符的摘要,取不到时为空串。
205
+ */
206
+ function bodyExcerpt(body) {
207
+ const text = typeof body === "string" ? body : body !== null && typeof body === "object" ? safeJson(body) : "";
208
+ const cleaned = scrub(text);
209
+ return cleaned.length > 300 ? `${cleaned.slice(0, 300)}…` : cleaned;
210
+ }
211
+
212
+ /**
213
+ * 把降级原因翻译成给用户看的中文:一句标题 + 一句可执行的下一步。
214
+ *
215
+ * 为什么不能直接把 reason 抛给用户:遥测里的 reason(`timeout`、`http-500`、
216
+ * `cooldown`…)是给机器看的审计字段,用户看到「http-500」并不知道该改什么。
217
+ * 而这四类失败的**修复动作完全不同**:
218
+ *
219
+ * - timeout → 调大 timeoutMs 或关掉 thinkMode(服务冷启动)
220
+ * - network-error → 查链路 / DNS / 代理(本机问题)
221
+ * - http-5xx → 什么都不用改,找服务商(服务端故障)
222
+ * - cooldown → 等冷却结束,别手动干预(保护机制)
223
+ *
224
+ * 混成一句「决策失败」等于让用户自己猜,这正是本模块存在的理由。
225
+ *
226
+ * 输出约束:title 与 hint 里不出现审计字段名(`risk=`、`p=`)和裸小数,
227
+ * 也不出现毫秒级的小数延迟——这些是日志语言,不是用户语言。
228
+ *
229
+ * @param {unknown} reason - 降级原因,如 `timeout` / `http-500` / `cooldown`。
230
+ * @param {object} [extra] - 可选上下文。
231
+ * @param {string} [extra.message] - 网络层原始错误信息(network-error 分支会附上)。
232
+ * @param {string} [extra.requestId] - 服务商请求 id(5xx 分支优先使用)。
233
+ * @param {string|object} [extra.body] - 服务商响应体(5xx 分支从中找请求 id 或作为摘要附上)。
234
+ * @param {number} [extra.timeoutMs] - 本次超时预算(仅用于让提示更贴近现场)。
235
+ * @returns {{ title: string, hint: string }} 中文标题与可执行提示。
236
+ */
237
+ export function describeFailure(reason, extra = {}) {
238
+ const info = extra !== null && typeof extra === "object" ? extra : {};
239
+ const reasonText =
240
+ typeof reason === "string" && reason.trim().length > 0
241
+ ? reason.trim()
242
+ : reason === undefined || reason === null
243
+ ? "未知"
244
+ : String(reason);
245
+
246
+ if (reasonText === "timeout") {
247
+ // 取整后再写进文案:给用户看的句子里不放裸小数。
248
+ const budget =
249
+ typeof info.timeoutMs === "number" && Number.isFinite(info.timeoutMs) && info.timeoutMs > 0
250
+ ? `这次用的是 ${Math.round(info.timeoutMs)}ms 的预算。`
251
+ : "";
252
+ return {
253
+ title: "请求超时",
254
+ hint:
255
+ `决策请求在超时预算内没有返回。${budget}这是服务端冷启动的典型表现:服务刚被唤醒时首发通常要 7–16 秒,` +
256
+ `但这次超时的请求已经把服务端焐热,紧接着重发一次通常 1–2 秒内就能返回。` +
257
+ `可以把 timeoutMs 调大(默认 20000),或把 thinkMode 设为 never 让服务端跳过思考阶段。`,
258
+ };
259
+ }
260
+
261
+ if (reasonText === "network-error") {
262
+ const detail = scrub(info.message);
263
+ return {
264
+ title: "网络连接失败",
265
+ hint:
266
+ `请求没有到达服务端(fetch failed / ECONNRESET 一类,实测多在 5004–5557ms 后失败)。` +
267
+ `这通常是本机链路、DNS 或代理问题,与服务商的模型配置无关:先确认能否直连该端点,` +
268
+ `如果本机必须走系统代理,用 NODE_USE_ENV_PROXY=1 让 Node 读取代理环境变量。` +
269
+ (detail.length > 0 ? `底层错误:${detail}` : ""),
270
+ };
271
+ }
272
+
273
+ const statusMatch = /^http-(\d{3})$/i.exec(reasonText);
274
+ const status = statusMatch === null ? undefined : Number(statusMatch[1]);
275
+ if (status !== undefined && status >= 500 && status <= 599) {
276
+ const requestId = pickRequestId(info);
277
+ const excerpt = bodyExcerpt(info.body);
278
+ const cause =
279
+ `决策服务端自己返回了错误(HTTP ${status})。这是服务商侧的故障(例如 Database error / ` +
280
+ `Task request failed),本地配置无需改动;重试也不会变好,只会多烧一次额度。`;
281
+ const hint =
282
+ requestId.length > 0
283
+ ? `${cause}请求 id:${requestId},把它提供给服务商,他们可以据此定位这次请求。`
284
+ : excerpt.length > 0
285
+ ? `${cause}这次没拿到请求 id,请联系服务商并附上响应体:${excerpt}`
286
+ : `${cause}这次没拿到请求 id,请联系服务商并附上响应体与发生时间。`;
287
+ return { title: "服务端内部错误", hint };
288
+ }
289
+
290
+ if (reasonText === "cooldown") {
291
+ return {
292
+ title: "冷却中",
293
+ hint:
294
+ `前几次失败已经触发了冷却保护,冷却期间不再向服务端发请求,以免连续失败把额度烧光。` +
295
+ `冷却结束后会自动恢复,不需要手动操作;如果冷却反复出现,先按上一次失败的原因排查(超时、网络或服务端故障)。`,
296
+ };
297
+ }
298
+
299
+ // 其余原因(http-4xx、auth-failed、no-key、invalid-json 等):保留 reason 原文,
300
+ // 它是用户搜文档、贴给服务商时唯一能对上的标识。
301
+ return {
302
+ title: "请求失败",
303
+ hint:
304
+ `决策请求没有成功(原因:${reasonText})。请检查端点地址、模型名与 API key 是否匹配:` +
305
+ `地址要指向决策接口,模型名要与该服务商可用列表一致,密钥要与服务商对应。` +
306
+ `修正后重试一次;如果这个原因持续出现,把原因和端点地址一起反馈给服务商。`,
307
+ };
308
+ }
package/lib/scope.js ADDED
@@ -0,0 +1,67 @@
1
+ import { posix, win32 } from "node:path";
2
+
3
+ export const SCOPE_KINDS = Object.freeze({
4
+ outsideWorkspace: "outside-workspace",
5
+ awaitingUser: "awaiting-user",
6
+ unknown: "unknown",
7
+ });
8
+ export const SCOPE_NONE = "none";
9
+
10
+ function pathApi(value) {
11
+ return /^[A-Za-z]:[\\/]|^\\\\/.test(value) ? win32 : posix;
12
+ }
13
+
14
+ function absolutePath(value, cwd) {
15
+ if (typeof value !== "string" || !value.trim() || /[\0\r\n$`*?]|^~|^[A-Za-z]:(?![\\/])/.test(value)) return undefined;
16
+ const api = pathApi(value);
17
+ if (api.isAbsolute(value)) return { path: api.normalize(value), api };
18
+ if (typeof cwd !== "string" || !pathApi(cwd).isAbsolute(cwd)) return undefined;
19
+ const baseApi = pathApi(cwd);
20
+ return { path: baseApi.resolve(cwd, value), api: baseApi };
21
+ }
22
+
23
+ export function isUnderRoots(target, roots, cwd) {
24
+ const resolved = absolutePath(target, cwd);
25
+ if (!resolved || !Array.isArray(roots) || !roots.length) return true;
26
+ return roots.some((root) => {
27
+ const base = absolutePath(root, cwd);
28
+ if (!base || base.api !== resolved.api) return false;
29
+ const relative = resolved.api.relative(base.path, resolved.path);
30
+ return relative === "" || (!relative.startsWith(`..${resolved.api.sep}`) && relative !== ".." && !resolved.api.isAbsolute(relative));
31
+ });
32
+ }
33
+
34
+ export function pathTargetsOf(tool, args) {
35
+ if (!args || typeof args !== "object") return [];
36
+ if (tool === "apply_patch") {
37
+ const patch = args.input ?? args.patch;
38
+ if (typeof patch !== "string") return [];
39
+ return [...patch.matchAll(/^\*\*\* (?:(?:Update|Add|Delete|Move) File|Move to): (.+)$/gm)]
40
+ .map((match) => match[1].trim()).filter(Boolean);
41
+ }
42
+ return [args.file_path, args.path, args.file, args.destination, args.new_path]
43
+ .filter((value) => typeof value === "string" && value.length > 0);
44
+ }
45
+
46
+ export function pathTargetOf(tool, args) {
47
+ return pathTargetsOf(tool, args)[0];
48
+ }
49
+
50
+ export function classifyWorkspaceTarget(options) {
51
+ const targets = pathTargetsOf(options.tool, options.args);
52
+ const roots = options.roots;
53
+ if (!targets.length || !Array.isArray(roots) || !roots.length || roots.some((root) => !absolutePath(root, options.cwd))) {
54
+ return { kind: SCOPE_KINDS.unknown, targets, coverage: "unknown", canonical: "unknown" };
55
+ }
56
+ const resolved = targets.map((target) => absolutePath(target, options.cwd));
57
+ const partial = resolved.some((target) => !target);
58
+ const outside = targets.filter((target, index) => resolved[index] && !isUnderRoots(target, roots, options.cwd));
59
+ if (!outside.length && partial) return { kind: SCOPE_KINDS.unknown, targets, coverage: "partial", canonical: "unknown" };
60
+ return {
61
+ kind: outside.length ? SCOPE_KINDS.outsideWorkspace : SCOPE_NONE,
62
+ targets,
63
+ outside,
64
+ coverage: partial ? "partial" : "lexical-only",
65
+ canonical: "unknown",
66
+ };
67
+ }