rcs-context 0.0.0-stage → 0.1.39

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.
@@ -0,0 +1,431 @@
1
+ // Deterministic durability policy for Cycle 2 proposals.
2
+ //
3
+ // The model is good at copying text and bad at deciding whether a user message is
4
+ // a standing project rule or a one-off task/question. That decision is therefore
5
+ // made here, from the archived text alone, before any model call and again inside
6
+ // the evidence validator. The model still chooses the exact quote; this module
7
+ // only bounds where a quote may come from and how an overlapping predicate is
8
+ // named.
9
+ //
10
+ // Nothing here reads a clock, a model, or mutable state. Every result is a pure
11
+ // function of the input text, so a replay produces the same spans and predicates.
12
+
13
+ export const DURABILITY_POLICY_VERSION = 'durable-v2'
14
+
15
+ const WHITESPACE_CHAR = /\s/
16
+
17
+ const RE = {
18
+ // Prompt-injection / role confusion. Explicit instructions to rewrite memory are
19
+ // never evidence, even when the text uses a durable-sounding label.
20
+ injection: [
21
+ /^\s*(SYSTEM|SYSTEM\s+OVERRIDE|系统指令|系统提示|SYSTEM\s+PROMPT)\s*[::]/i,
22
+ /(忽略|无视|忘记).{0,8}(之前|以上|前面|原有).{0,6}(规则|指令|约束|设定)/,
23
+ /(把|将)(这条|这句话|上面这句|此条).{0,12}(写成|记为|记录为|设为|当作).{0,8}(约束|规则|偏好|决策|事实)/,
24
+ ],
25
+ assistantProse: [
26
+ /^\s*(我会记住|我已记住|已经记住|已为你|已完成|已处理|总结如下|根据以上|以下是)/,
27
+ /(我(会|将)(记住|记录)|已(经)?(记录|记住)|根据(你|您)的(要求|偏好))/,
28
+ ],
29
+ codeDump: [
30
+ /^\s*```/,
31
+ /^\s*(const|let|var|function|class|import|export)\s/m,
32
+ ],
33
+ question: [
34
+ /[??]\s*$/,
35
+ /(吗|呢)\s*[。.]?\s*$/,
36
+ // A request for the current turn ("简单些好吗", "这样可以吗") is not a standing
37
+ // fact even when it carries a modal like 不要. The marker can sit mid-sentence.
38
+ /(好吗|行吗|可以吗|好不好|成吗|行不行|可不可以)/,
39
+ // A choice or a direct question is not a rule even without a question mark.
40
+ /^\s*(?:要不要|是否要|是否应该|为什么|为何|哪种|哪个|哪一个)/,
41
+ /(?:用|选|采用|使用|走)[^。!?;]{0,12}还是[^。!?;]{0,16}/,
42
+ /(?:有何|有哪些)[^。!?;]{0,12}[??]/,
43
+ /(?:是什么|为什么)[^。!?;]{0,12}[??]/,
44
+ /(?:是否(?:还)?(?:需要|要|应该|可以|能))/,
45
+ /(?:不是|难道)[^。!?;]{0,20}吗/,
46
+ ],
47
+ uncertain: [
48
+ /(我(还)?没(想好|定|确定)|还没想好|还没定|未定|待定|等有结论|再说吧|回头再说|具体方案还没定|拿不准|不确定(要|是否|用|选|改|做)|你(觉得|看)呢)/,
49
+ ],
50
+ transient: [
51
+ /(这次|本次|临时|暂时|先试|试试看|试一下|跑一下|抓一次|测试完|先这样)/,
52
+ // "这条回复/这个任务" scopes an otherwise durable-looking preference or rule
53
+ // to the current request.
54
+ /(?:这|本)(?:条|轮|次|个)(?:回复|回答|任务|请求|消息)/,
55
+ ],
56
+ plan: [
57
+ /(打算|计划|准备(?!好)|回头|下次再|以后再说|等有结论)/,
58
+ /(如果|假如).{0,24}(发布|上线|发版|合并|关掉|恢复原设置)/,
59
+ ],
60
+ transientError: [
61
+ /(刚才|刚刚|这次|本次).{0,24}(报错|报了|错误|失败|超时|异常).{0,12}(重试|再试|试试|一下|再跑|看下)/,
62
+ /(构建|测试|运行|安装).{0,8}(失败|报错).{0,12}(重试|再跑|再试)/,
63
+ ],
64
+ fileObservation: [
65
+ /(里写着|上写着|里已经写着|历史记录|只是历史|已经有.{0,16}(脚本|配置|文件|依赖))/,
66
+ /(README|package\.json|源码|文档)(里|中)\s*(写|说|提到|记录|配置)/i,
67
+ ],
68
+ // A tool or runtime that is unavailable, paired in the same sentence with the
69
+ // substitute to use. The substitute is required: "bash 不可用" alone is a
70
+ // status report, and "bash 不可用,试试 pwsh" is a one-off suggestion.
71
+ environment: [
72
+ /(?:shell|终端|命令行|解释器|运行时|运行环境|工具链|pwsh|powershell|bash|cmd(?:\.exe)?|python\d*(?:\.\d+)?|node(?:\.js)?)[^。!?;;]{0,40}?(?:不可用|用不了|不能用|没装|无法使用|不支持|不适配)[^。!?;;]{0,40}?(?:得|必须|只能|应当|应该|改用|统一|一律|用)\s*(?:用|使用|走)?\s*[A-Za-z]/i,
73
+ ],
74
+ assignmentOpen: [
75
+ /^\s*(你负责|你来做|你来|请你|请|帮我|麻烦|先|现在|这次|本次|临时|实现|修复|补足|运行|读取|写入|检查|启动|关闭|把|将|给我|重试|再试)/,
76
+ ],
77
+ // A command verb at the start of the message.
78
+ startImperative: [
79
+ /^\s*(?:改成|改为|调成|换成|部署|安装|卸载|创建|新建|添加|删除|移动|重命名|读取|写入|运行|执行|检查|启动|关闭|拉取|提交(?!前|后|信息|说明)|推送(?!前|后)|合并(?!前|后)|升级|降级|提升|达到|优化|降低|减少|增加|实现|修复)/,
80
+ ],
81
+ // A work chain ("…,然后重新部署") is request-scoped even when its opening clause
82
+ // looks like a decision ("端口改成 8080").
83
+ actionChain: [
84
+ /(然后|再|完成后|做完|之后|最后|接着)/,
85
+ /并(?:把|将|告诉|输出|贴|汇报|确认|运行|执行|检查|部署|安装|创建|添加|删除|修改|修复|重命名|读取|写入|提交|推送|合并|升级)/,
86
+ ],
87
+ directive: [
88
+ /(改成|改为|调成|换成|转为|部署|安装|卸载|创建|新建|添加|删除|移动|重命名|读取|写入|运行|执行|检查|启动|关闭|拉取|提交|推送|合并|升级|降级|提升|达到|优化|降低|减少|增加|实现|修复|清理|去除|制造|写|建|搭|明确|设置|配置|监测|打包|审验|传递|解决|调集|整理|修正|跑|做|看|改|加)/,
89
+ ],
90
+ // A first/second-person request frame anywhere in the message. It is only a task
91
+ // when paired with a directive and no standing-rule signal.
92
+ requestFrame: [
93
+ /(?:我(?:需要|要|想|希望)|你(?:需要|要|得|应该|可以)|你的任务|你要做的|我说(?:把|将|要)?|我们|请你|帮我|麻烦|给我|替我|可以做|再审|重新)/,
94
+ ],
95
+ // A status report, observation, pasted log, or acknowledgement. These carry
96
+ // project nouns and modals but describe what happened, not what must hold.
97
+ statusFrame: [
98
+ /^\s*(?:我注意到|我发现|我的理解|从全局来看|我不得不|已经|刚刚|目前|我在|我向|啊[,,]|都没有用|没有用|还是不行|已验证|已修复|已解决|已完成|成功了|搞定了|NO[,,])/i,
99
+ /^\s*(?:🧠|🟢|📋|✅|\[当前任务\]|Context Inject)/i,
100
+ /^(?:A|B|C)[,,]\s*我们(?:已经|摸清|知道)/,
101
+ ],
102
+ strongDurable: [
103
+ /(规则|约定|规范|标准|原则|约束)/,
104
+ /(记下|记住|记录一下|请记录|写进记忆|加入记忆)/,
105
+ /(以后|今后|往后|未来|长期|永久|始终|永远|每次|下一次)/,
106
+ /\b(?:add|put|keep)\s+(?:this|it|that)\s+(?:in|into|to)\s+(?:your\s+)?memory\b/i,
107
+ /\bremember\s+(?:that|this)\b/i,
108
+ /\bkeep\s+in\s+mind\b/i,
109
+ ],
110
+ explicitRule: [
111
+ /(规则|约定|规范|标准|原则|约束)\s*[::]/,
112
+ /(项目|业务|接口|部署|测试|命名|结算|开发|团队|代码|提交|评测|安全|数据|协作|发布|架构|设计)[^,。;:]{0,6}(规则|约定|规范|标准|原则|约束)/,
113
+ /(这是|那是|作为|按照|根据|遵循)[^。;]{0,6}(规则|约定|规范|标准|原则|约束)/,
114
+ ],
115
+ designConstraint: [
116
+ /(?:不能|不得|不可|必须|应当|应该)(?:丢失|丢|破坏|省略|跳过|绕过|混用|忽略|覆盖)/,
117
+ /(?:是必要的|是危险的|必不可少|必须保留|不可替代)/,
118
+ ],
119
+ memo: [
120
+ /(记下|记住|记录一下|请记录|写进记忆|加入记忆)/,
121
+ /\b(?:add|put|keep)\s+(?:this|it|that)\s+(?:in|into|to)\s+(?:your\s+)?memory\b/i,
122
+ /\bremember\s+(?:that|this)\b/i,
123
+ /\bkeep\s+in\s+mind\b/i,
124
+ ],
125
+ future: [
126
+ /(以后|今后|往后|未来|长期|永久|始终|永远|每次|下一次)/,
127
+ ],
128
+ universal: [
129
+ /(一律|统一|总是|所有|任何|全部|默认|优先|从不|绝不)/,
130
+ ],
131
+ modal: [
132
+ /(必须|不得|不允许|禁止|不能|(?<!要)不要|别(?!人)|只允许|只能|最多|至少|应当|应该|可回滚|全绿|不得超过|保持|视为|固定为|需要固定)/,
133
+ ],
134
+ decision: [
135
+ /(决定|确定|定版|拍板|采用|改用|选用|迁移到|改为|改成|为准|最终方案|而不是|不引入|不再接受|不再适用|作废|撤回|取消|废弃|停用|撤销|回退|改回|不再使用)/,
136
+ ],
137
+ preference: [
138
+ /(偏好|更喜欢|喜欢|习惯|倾向|希望|不喜欢|prefer|would rather)/i,
139
+ ],
140
+ interaction: [
141
+ /(回答|回复|答复|沟通|先给结论|先给细节|先说明|先提问|提问|不要猜|命名|格式|风格|行文|报告|绝对路径|相对路径)/,
142
+ ],
143
+ retraction: [
144
+ /(作废|撤回|取消|废弃|停用|撤销|不再适用|不再使用|回退|改回|不用了|不要了)/,
145
+ ],
146
+ english: [
147
+ /\b(decided|decision|prefer|preference|must|never|always|should|requires?|required|avoid|convention|rule|constraint|from now on)\b/i,
148
+ ],
149
+ domain: [
150
+ /(金额|折扣|优惠券|结算|测试|依赖|接口|部署|生产|字段|单位|四舍五入|端口|目录|路径|文件|版本|编码|日志|数据库|缓存|并发|安全|权限|响应|请求|API|JSON|错误|时间|时区|精度|上限|下限|类型|编号|序号|schema)/i,
151
+ ],
152
+ }
153
+
154
+ function any(patterns, text) {
155
+ for (const pattern of patterns) {
156
+ if (pattern.test(text)) return pattern
157
+ }
158
+ return null
159
+ }
160
+
161
+ function describe(pattern) {
162
+ return pattern?.source ?? null
163
+ }
164
+
165
+ // A period followed by whitespace and an uppercase letter/quote starts a new
166
+ // English sentence. A period inside a decimal, version, path, or URL is followed
167
+ // by a non-space character and therefore stays part of the token.
168
+ function asciiSentenceEnd(text, index) {
169
+ if (index + 1 >= text.length || !WHITESPACE_CHAR.test(text[index + 1])) return false
170
+ let next = index + 1
171
+ while (next < text.length && WHITESPACE_CHAR.test(text[next])) next++
172
+ if (next >= text.length) return true
173
+ return /[A-Z"“「(]/.test(text[next])
174
+ }
175
+
176
+ // Sentence spans that keep their offsets. Chinese terminators, line breaks, and
177
+ // unambiguous English sentence endings end a sentence; other ASCII periods do not,
178
+ // because a decimal/version/path can contain one.
179
+ export function sentenceSpans(text) {
180
+ if (typeof text !== 'string' || text === '') return []
181
+ const spans = []
182
+ let start = 0
183
+ for (let index = 0; index < text.length; index++) {
184
+ const char = text[index]
185
+ const ends = char === '\n' || char === '。' || char === '!' || char === '?' ||
186
+ char === ';' || char === ';' || (char === '.' && asciiSentenceEnd(text, index))
187
+ if (ends) {
188
+ const end = index + 1
189
+ if (text.slice(start, end).trim() !== '') spans.push({ start, end })
190
+ start = end
191
+ }
192
+ }
193
+ if (start < text.length && text.slice(start).trim() !== '') {
194
+ spans.push({ start, end: text.length })
195
+ }
196
+ return spans
197
+ }
198
+
199
+ function sentenceSignals(text) {
200
+ return {
201
+ explicitRule: any(RE.explicitRule, text),
202
+ designConstraint: any(RE.designConstraint, text),
203
+ memo: any(RE.memo, text),
204
+ future: any(RE.future, text),
205
+ universal: any(RE.universal, text),
206
+ modal: any(RE.modal, text),
207
+ decision: any(RE.decision, text),
208
+ preference: any(RE.preference, text),
209
+ interaction: any(RE.interaction, text),
210
+ retraction: any(RE.retraction, text),
211
+ english: any(RE.english, text),
212
+ environment: any(RE.environment, text),
213
+ domain: any(RE.domain, text),
214
+ }
215
+ }
216
+
217
+ function hasSignal(signals) {
218
+ return Object.values(signals).some(Boolean)
219
+ }
220
+
221
+ function strongSignal(signals) {
222
+ return Boolean(signals.explicitRule || signals.memo || signals.future)
223
+ }
224
+
225
+ function mergeSpans(units) {
226
+ const merged = []
227
+ for (const unit of units) {
228
+ const last = merged[merged.length - 1]
229
+ // Adjacent sentences must stay separate: merging them lets a predicate cue from
230
+ // one sentence authorize a quote from the next ("回答先给结论" becoming a
231
+ // constraint because the previous sentence said "项目约定").
232
+ if (last && last.end > unit.start) {
233
+ last.end = Math.max(last.end, unit.end)
234
+ last.reasons.push(...unit.reasons)
235
+ continue
236
+ }
237
+ merged.push({ ...unit, reasons: [...unit.reasons] })
238
+ }
239
+ return merged
240
+ }
241
+
242
+ function signalsOf(signals) {
243
+ return Object.entries(signals)
244
+ .filter(([, pattern]) => Boolean(pattern))
245
+ .map(([name]) => name)
246
+ }
247
+
248
+ // Pure classification of one archived human message.
249
+ //
250
+ // `eligible` means a proposal may be generated from it. `spans` are the only text
251
+ // ranges a quote may occupy; a quote that crosses an ineligible sentence is
252
+ // refused by the validator even when the message as a whole is eligible.
253
+ export function analyzeDurableUtterance(text) {
254
+ if (typeof text !== 'string' || text.trim() === '') {
255
+ return {
256
+ eligible: false, reason: 'empty', reasons: ['empty'], signals: [], spans: [],
257
+ policyVersion: DURABILITY_POLICY_VERSION,
258
+ }
259
+ }
260
+ // Keep the original offsets: callers compare these spans with an indexOf result
261
+ // on the archived text, so trimming here would shift every unit.
262
+ const raw = text
263
+ const match = (patterns) => any(patterns, raw)
264
+ const injection = match(RE.injection)
265
+ const assistantProse = match(RE.assistantProse)
266
+ const codeDump = match(RE.codeDump)
267
+ const question = match(RE.question)
268
+ const uncertain = match(RE.uncertain)
269
+ const transient = match(RE.transient)
270
+ const plan = match(RE.plan)
271
+ const transientError = match(RE.transientError)
272
+ const fileObservation = match(RE.fileObservation)
273
+ const environment = match(RE.environment)
274
+ const assignmentOpen = match(RE.assignmentOpen)
275
+ const startImperative = match(RE.startImperative)
276
+ const actionChain = match(RE.actionChain)
277
+ const requestFrame = match(RE.requestFrame)
278
+ const directive = match(RE.directive)
279
+ const status = match(RE.statusFrame)
280
+ const strong = match(RE.strongDurable)
281
+ const future = match(RE.future)
282
+ const explicitRule = match(RE.explicitRule)
283
+ const universal = match(RE.universal)
284
+ const decision = match(RE.decision)
285
+ const interaction = match(RE.interaction)
286
+ const preference = match(RE.preference)
287
+ // A commitment outranks an action verb: "定版为 p1,不再接受" and "改用 worktree"
288
+ // are decisions even though they contain 提交/执行.
289
+ const commitment = /(决定|定版|拍板|采用|选用|选定|不再接受|不再适用|不再使用|作废|撤回|废弃|弃用|放弃|改用|迁移到|最终方案|为准)/.test(raw)
290
+
291
+ // Precedence is intentional. A transient or reported message must not become
292
+ // durable just because it contains a decision verb ("临时决定…", "README 里写着
293
+ // 决定…"), and a question must not become a fact because it contains "以后".
294
+ let reason = null
295
+ if (injection) reason = 'injection'
296
+ else if (assistantProse) reason = 'assistant-prose'
297
+ else if (codeDump) reason = 'code-dump'
298
+ else if (question) reason = 'question'
299
+ else if (uncertain) reason = 'uncertain'
300
+ else if (transient && !future) reason = 'transient'
301
+ else if (plan) reason = 'plan'
302
+ else if (transientError) reason = 'transient-error'
303
+ else if (fileObservation) reason = 'file-observation'
304
+ else if (status && !strong && !explicitRule && !commitment) {
305
+ reason = 'status-report'
306
+ }
307
+
308
+ // An imperative frame is request-scoped unless the same message also carries a
309
+ // standing-rule signal. This is the scope criterion, not a list of task words:
310
+ // "帮我加一个测试文件" is work; "先给结论" is a preference; "把金额一律…" is a rule.
311
+ // A commitment always outranks the action frame; a standing-rule signal exempts a
312
+ // request frame. A bare setting change ("接口端口改为 9000") has neither an opener
313
+ // nor an action chain, so it stays a decision rather than becoming a task.
314
+ // "所有文件" is an object, not a standing scope: a universal word exempts an
315
+ // imperative opener ("把金额一律…") but not a first-person request ("我需要你清理
316
+ // 所有文件").
317
+ const openerFrame = Boolean(assignmentOpen || startImperative || (actionChain && directive))
318
+ const requestFrameTask = Boolean(requestFrame && directive)
319
+ const assignment = Boolean(
320
+ (openerFrame && !strong && !explicitRule && !universal && !interaction && !preference &&
321
+ !environment && !commitment) ||
322
+ (requestFrameTask && !strong && !explicitRule && !preference &&
323
+ !environment && !commitment),
324
+ )
325
+ if (reason === null && assignment) reason = 'task-assignment'
326
+
327
+ const spans = sentenceSpans(raw)
328
+ const units = []
329
+ for (const span of spans) {
330
+ const sentence = raw.slice(span.start, span.end)
331
+ const signals = sentenceSignals(sentence)
332
+ if (!hasSignal(signals)) continue
333
+ if (assignment && !strongSignal(signals)) continue
334
+ units.push({
335
+ start: span.start,
336
+ end: span.end,
337
+ reasons: signalsOf(signals),
338
+ text: sentence,
339
+ })
340
+ }
341
+
342
+ // A mixed message is judged by its rule clause, not by a question/plan in another
343
+ // sentence. The override is limited to question/uncertain/plan; a reported file or
344
+ // an injection stays excluded because its authority is wrong.
345
+ const strongSentences = units.some(({ reasons }) => reasons.some((name) =>
346
+ name === 'explicitRule' || name === 'designConstraint' || name === 'memo' ||
347
+ name === 'future' || name === 'decision' ||
348
+ (name === 'modal' && reasons.includes('domain'))))
349
+ // A universal word in a status report ("不显示任何内容") is an object quantifier,
350
+ // not a standing rule; only an explicit label, a design invariant, a memo, or a
351
+ // future commitment lifts the status frame.
352
+ const ruleSentences = units.some(({ reasons }) => reasons.some((name) =>
353
+ name === 'explicitRule' || name === 'designConstraint' || name === 'memo' ||
354
+ name === 'future'))
355
+ // A question only yields to a sentence that states a rule outright. "我们是不是
356
+ // 应该改用 Postgres?" contains a decision verb but is still a question; a plain
357
+ // decision/future cue must not turn it into a fact.
358
+ const questionRule = units.some(({ reasons }) => reasons.some((name) =>
359
+ name === 'explicitRule' || name === 'designConstraint' || name === 'memo'))
360
+ if (ruleSentences && reason === 'status-report') reason = null
361
+ if (questionRule && reason === 'question') reason = null
362
+ if (strongSentences && (reason === 'uncertain' || reason === 'plan')) reason = null
363
+
364
+ if (reason === null && units.length === 0) reason = 'no-durable-signal'
365
+
366
+ return {
367
+ eligible: reason === null,
368
+ reason,
369
+ reasons: reason === null ? [] : [reason],
370
+ signals: {
371
+ injection: describe(injection),
372
+ assistantProse: describe(assistantProse),
373
+ codeDump: describe(codeDump),
374
+ question: describe(question),
375
+ uncertain: describe(uncertain),
376
+ transient: describe(transient),
377
+ plan: describe(plan),
378
+ transientError: describe(transientError),
379
+ fileObservation: describe(fileObservation),
380
+ environment: describe(environment),
381
+ requestFrame: describe(requestFrame),
382
+ status: describe(status),
383
+ startImperative: describe(startImperative),
384
+ actionChain: describe(actionChain),
385
+ directive: describe(directive),
386
+ assignment: assignment ? 'assignment' : null,
387
+ strongDurable: describe(strong),
388
+ future: describe(future),
389
+ },
390
+ spans: mergeSpans(units).map(({ start, end, reasons }) => ({ start, end, reasons })),
391
+ policyVersion: DURABILITY_POLICY_VERSION,
392
+ }
393
+ }
394
+
395
+ // The taxonomy is defined by these cues, in this order. The model's predicate is
396
+ // advisory: when a cue is present the deterministic label wins, because an
397
+ // undefined taxonomy is what produced content-correct/predicate-wrong candidates.
398
+ export function resolveQuotePredicate(text) {
399
+ if (typeof text !== 'string') return null
400
+ const signals = sentenceSignals(text)
401
+ if (signals.explicitRule) return { predicate: 'project.constraint', rule: 'explicit-rule-noun' }
402
+ if (signals.designConstraint) return { predicate: 'project.constraint', rule: 'design-constraint' }
403
+ if (signals.environment) return { predicate: 'project.constraint', rule: 'environment-cue' }
404
+ if (signals.preference || signals.interaction) {
405
+ return { predicate: 'project.preference', rule: signals.preference ? 'preference-cue' : 'interaction-cue' }
406
+ }
407
+ if (signals.decision) return { predicate: 'project.decision', rule: 'decision-cue' }
408
+ if (signals.english) {
409
+ if (/\b(prefer|preference|would rather)\b/i.test(text)) {
410
+ return { predicate: 'project.preference', rule: 'english-preference' }
411
+ }
412
+ if (/\b(decided|decision|chose|instead of)\b/i.test(text)) {
413
+ return { predicate: 'project.decision', rule: 'english-decision' }
414
+ }
415
+ return { predicate: 'project.constraint', rule: 'english-deontic' }
416
+ }
417
+ if (signals.universal || signals.modal || signals.retraction) {
418
+ return { predicate: 'project.constraint', rule: 'invariant-cue' }
419
+ }
420
+ // A domain noun alone ("数据库使用 SQLite") says what the statement is about,
421
+ // not which taxonomy bucket it belongs to. Keep the model's label rather than
422
+ // turning every unlabelled domain sentence into a constraint.
423
+ return null
424
+ }
425
+
426
+ export function quoteWithinSpans(spans, start, end) {
427
+ if (!Array.isArray(spans)) return false
428
+ return spans.some((span) =>
429
+ Number.isInteger(span.start) && Number.isInteger(span.end) &&
430
+ start >= span.start && end <= span.end)
431
+ }