aura-basetools 0.7.0 → 0.13.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.
@@ -23,24 +23,28 @@ import { indexFile, locateWith, normSpace } from './anchor-core.mjs'
23
23
  /**
24
24
  * 定位一份源码里的改动点。
25
25
  *
26
- * 四层寻址,按"模型要提供什么"从强到弱排列。每层覆盖上一层够不到的场景,
26
+ * 五层寻址,按"模型要提供什么"从强到弱排列。每层覆盖上一层够不到的场景,
27
27
  * 但**都不要求模型复现字节**——这是它们与 `edit` 的根本差别:
28
28
  *
29
- * 1. `target` —— 符号名。不读文本,对内容记错免疫。适合改整个函数/类/类型。
30
- * 2. `lines` —— 行号区间。模型刚用 `read` 看过文件,输出就是 `<行号>: <文本>`,
29
+ * 1. `intent` —— 一段模糊意图(符号名片段 / 短语 / 近似原文)。引擎按固定次序收敛:
30
+ * 精确符号 → 符号子串 → 文本锚点;收敛不了就报歧义并给候选,绝不猜。
31
+ * 2. `target` —— 符号名。不读文本,对内容记错免疫。适合改整个函数/类/类型。
32
+ * 3. `lines` —— 行号区间。模型刚用 `read` 看过文件,输出就是 `<行号>: <文本>`,
31
33
  * `grep` 也给行号——**位置是它本来就知道的信息**,我却一直只让它给内容。
32
34
  * 把行号纳入地址空间后,符号体内的任意一行都变得可精确寻址。
33
- * 3. `lineCue` —— 一个词。当模型记不住行号时,说"就在有 resolvePolicy 的那一行"。
35
+ * 4. `lineCue` —— 一个词。当模型记不住行号时,说"就在有 resolvePolicy 的那一行"。
34
36
  * 只接受**全文件唯一**的线索;不唯一则报歧义并给出候选行号,绝不猜。
35
- * 4. `anchor` —— 记得的原文。五级降级匹配,容错性最好但要求模型复现内容。
37
+ * 5. `anchor` —— 记得的原文。五级降级匹配,容错性最好但要求模型复现内容。
36
38
  *
37
- * 前两层是**精确**的(要么命中要么报错),后两层是**容错**的。
38
- * 调用方可以传 `assert` 做内容校验,四层都适用——那才是防止"指到错的行"的那道闸。
39
+ * `intent` 与其余四层**互斥**(由 `planHunks` 判):同时给两个地址时谁说了算没有正确答案。
40
+ * 前两层偏**精确**(要么命中要么报错),后三层偏**容错**。
41
+ * 调用方可以传 `assert` 做内容校验,五层都适用——那才是防止"指到错的行"的那道闸。
39
42
  * 判据是**包含且唯一**:这段文字必须在整个文件里只出现一处,且该处落在受寻址区间内
40
43
  * (短引文例外,仍要求与区间整段相等;推导见 {@link ASSERT_RELAX_MIN_CHARS})。
41
44
  *
42
45
  * @param {string} source - 源码全文
43
46
  * @param {object} request - 定位请求
47
+ * @param {string} [request.intent] - 模糊意图地址(符号名片段 / 短语 / 近似原文)
44
48
  * @param {string} [request.target] - 符号名(`name` / `kind:name` / `Outer/Inner` / `name#n`)
45
49
  * @param {{start:number,end:number}} [request.lines] - 1-based 闭区间行号
46
50
  * @param {string} [request.lineCue] - 一个词的线索,必须全文件唯一
@@ -114,7 +118,7 @@ function failure(code, message, candidates) {
114
118
  return {
115
119
  ...c,
116
120
  line: typeof c.startLine === 'number' ? c.startLine : null,
117
- ...(c.kind === undefined ? {} : { text: `${c.kind}${c.enclosing === undefined ? '' : ` in ${c.enclosing}`}` }),
121
+ ...(c.kind === undefined ? {} : { text: `${c.kind}${c.enclosing === undefined || c.enclosing === null ? '' : ` in ${c.enclosing}`}` }),
118
122
  ordinal: i + 1,
119
123
  }
120
124
  })
@@ -149,14 +153,25 @@ function failure(code, message, candidates) {
149
153
  function renderCandidates(rows) {
150
154
  const parts = []
151
155
  let spent = 0
156
+ /**
157
+ * `target=name#n` 的 `n` 必须是**同名的序号**,不是整份候选列表的序号:
158
+ * `resolveTarget` 把 `#n` 解释为该名字的第 n 个命中。旧代码拿全局下标,于是
159
+ * `fooHandler#1, barHandler#2, handler2#3, handler2#4` 里只有第一个能解析——
160
+ * 其余会把调用方引向 `STRUCT_NOT_FOUND`。唯一名字直接给 `target=name`,不带 `#`。
161
+ */
162
+ const nameCounts = new Map()
163
+ for (const c of rows) if (c.name !== undefined) nameCounts.set(c.name, (nameCounts.get(c.name) ?? 0) + 1)
164
+ const seen = new Map()
152
165
  for (const c of rows) {
166
+ let nth = null
167
+ if (c.name !== undefined) {
168
+ nth = (seen.get(c.name) ?? 0) + 1
169
+ seen.set(c.name, nth)
170
+ }
153
171
  if (parts.length >= CANDIDATE_LIMIT) break
154
172
  const at = typeof c.line === 'number' ? c.line : null
155
- const entry =
156
- `line ${c.line}${c.text === undefined ? '' : ` = ${quoteForMessage(c.text, at)}`}` +
157
- // 符号候选额外给出**可直接用的地址**:`#N` 就是 hits 里的序号(`resolveTarget` 的语义)。
158
- // 不给的话,模型只能看见“matched 2 symbols”,而不知道下一步该写什么。
159
- (c.name === undefined ? '' : ` (target=${c.name}#${c.ordinal})`)
173
+ const address = c.name === undefined ? '' : nameCounts.get(c.name) > 1 ? ` (target=${c.name}#${nth})` : ` (target=${c.name})`
174
+ const entry = `line ${c.line}${c.text === undefined ? '' : ` = ${quoteForMessage(c.text, at)}`}${address}`
160
175
  /**
161
176
  * 第一条**无论多长都要放**:一条候选都不印的清单等于没给,"有候选"当场变成一句空话。
162
177
  * 预算只从第二条起生效。
@@ -240,7 +255,7 @@ const DEFAULT_HINT_CHARS = 7600
240
255
  *
241
256
  * ## 行号前缀为什么是 `N| ` 而不是读回执的 `N: `
242
257
  *
243
- * 行号用的是**读回执同一套编号**(1-based,与 `aura_read` 打印的行号、与回执开头那句
258
+ * 行号用的是**读回执同一套编号**(1-based,与 `aura_basetools_read` 打印的行号、与回执开头那句
244
259
  * `lines A-B` 同一个尺子),调用方因此能把引文与读回执逐行对上。
245
260
  * 字形却刻意**不同形**:`N: ` 正是调用方会整块抄走的那种形状(`probes/quote-hygiene.mjs`
246
261
  * 把"行首 `数字: `"列为一类引文污染,且"整块抄走必然失配"),同形等于引诱它连前缀一起抄。
@@ -630,10 +645,52 @@ function assertVerdict(lines, startLine, endLine, tier, actual, assertText) {
630
645
  }
631
646
 
632
647
  /**
633
- * 定位一个区间。四层降级:符号身份 → 行号 → 唯一词 → 文本锚点。
648
+ * 意图地址(T16)的符号子串匹配:这个候选符号离 `near` 有多远。
649
+ *
650
+ * 判据只有一条:**`near` 落在符号的跨度里就是 0**,否则取到两端中较近的那一端。
651
+ * 与 `resolveTarget` 的 `|startLine - near|` 刻意不同:`intent` 匹配的是"大概想改哪个符号",
652
+ * 调用方给的 `near` 常常是**符号体内**的一行(它刚从 read 回执里抄来),拿起点算距离会把
653
+ * 一个明显在说的符号判成第二近。跨度包含 `near` ⇒ 距离 0,是这里唯一能自洽的口径。
654
+ *
655
+ * @param - 符号投影(含 startLine / endLine)
656
+ * @param near - 1-based 行号提示
657
+ * @returns 非负整数距离
658
+ */
659
+ function symbolDistanceFrom(symbol, near) {
660
+ if (near >= symbol.startLine && near <= symbol.endLine) return 0
661
+ return Math.min(Math.abs(near - symbol.startLine), Math.abs(near - symbol.endLine))
662
+ }
663
+
664
+ /**
665
+ * 多个子串命中里,`near` 能否**严格**收敛到一个(唯一最小值)。
666
+ *
667
+ * 不唯一(并列最小)⇒ null:那正是"该报歧义"的形状,猜测在这里比拒绝贵得多。
668
+ * @returns 命中的符号,或 null(near 没给 / 并列)
669
+ */
670
+ function nearestSymbolBySpan(symbols, near) {
671
+ let best = null
672
+ let bestDistance = Infinity
673
+ let ties = 0
674
+ for (const symbol of symbols) {
675
+ const distance = symbolDistanceFrom(symbol, near)
676
+ if (distance < bestDistance) {
677
+ bestDistance = distance
678
+ best = symbol
679
+ ties = 1
680
+ } else if (distance === bestDistance) ties += 1
681
+ }
682
+ return ties === 1 ? best : null
683
+ }
684
+
685
+ /**
686
+ * 定位一个区间。五层降级:意图 → 符号身份 → 行号 → 唯一词 → 文本锚点。
687
+ *
688
+ * `intent`(T16)是**最弱但最自然**的一层:调用方给一个符号名片段、短语或近似原文,
689
+ * 引擎先当符号名解析(精确/限定写法),再按符号名子串匹配,最后才落到文本匹配。
690
+ * 每一档都**确定性地**收敛到一个区间;收敛不了就报歧义并给候选,不猜。
634
691
  *
635
692
  * @param source - 文件全文
636
- * @param request - `target` / `lines` / `lineCue` / `anchor` 四选一,外加 `near` 与 `assert`
693
+ * @param request - `intent` / `target` / `lines` / `lineCue` / `anchor` 五选一,外加 `near` 与 `assert`
637
694
  * @returns 命中时 `{ok: true, startLine, endLine, tier, actual}`;未命中时 `{ok: false, code, message, ...}`。
638
695
  * `actual` 是该区间的原文,供调用方核对或展示。
639
696
  */
@@ -647,7 +704,7 @@ export function locate(source, request = {}) {
647
704
  * @param byNear - 这次命中是否靠 `near` 从歧义里收敛出来的(见 anchor-core 的四个产出点)。
648
705
  * 必须透传:工具回执的 `by near` 标记、以及由此给出的"下次直接用确定地址"建议都靠它。
649
706
  * 此前本函数只回 `{startLine, endLine, tier, actual}`,把引擎算好的 `byNear` 丢在中间——
650
- * 于是 `aura_edit` 渲染的 `by near` **恒为 false**(字段被声明必填、被渲染、却永远不出现),
707
+ * 于是 `aura_basetools_edit` 渲染的 `by near` **恒为 false**(字段被声明必填、被渲染、却永远不出现),
651
708
  * 降级命中对模型完全不可见。这是本项目最典型的"双义字段/死字段"形态。
652
709
  */
653
710
  const done = (startLine, endLine, tier, byNear = false) => {
@@ -688,13 +745,161 @@ export function locate(source, request = {}) {
688
745
  }
689
746
  }
690
747
 
748
+ /**
749
+ * 符号地址落在“整行区间比声明更宽”的行上 ⇒ 拒绝,不能把行内别的代码一起删掉。
750
+ *
751
+ * 这是 `LOCATOR_LINE_SHARED` 的另一半:同族判据(同一行还有别人),但“别人”
752
+ * 不一定是符号表里的兄弟——`import` / 指令 / 调用语句根本不是符号。
753
+ */
754
+ const sharedLineRefusal = (hit, shared, byWhat) => {
755
+ const label = clipReceiptLabel(filePath === null ? 'this file' : filePath, RECEIPT_LABEL_CHARS)
756
+ const advice = 'use old= with a unique fragment, handle=, or rewrite the whole file explicitly — nothing was written'
757
+ const message =
758
+ shared.side === 'header'
759
+ ? `cannot address "${hit.name}" by ${byWhat} in ${label}: the declaration starts on line ${shared.line} (${JSON.stringify(shared.text)}), so the addressed span would begin after its header and leave it behind; use lines= covering line ${shared.line}, or ${advice}`
760
+ : `cannot address "${hit.name}" by ${byWhat} in ${label}: line ${shared.line} also holds ${JSON.stringify(shared.text)} ${shared.side === 'before' ? 'before' : 'after'} the declaration, so replacing the symbol's whole-line span would delete it; ${advice}`
761
+ return { ok: false, code: 'LOCATOR_LINE_SHARED', message, shared }
762
+ }
763
+
764
+ // ── 第 0 层:意图地址(T16)。调用方给一段模糊的意图串,引擎确定性地落成精确区间。
765
+ //
766
+ // 解析次序(每一档都不猜,落不下来就报歧义并给候选):
767
+ // ① 已知语言时,先当符号名解析(`resolveTarget` 的精确 / 限定写法)——命中就算 symbol 档;
768
+ // ② 符号名没对上(或对上多个)⇒ 按符号名**子串**匹配(先区分大小写,再忽略大小写);
769
+ // ③ 一个符号都没命中(或语言未知)⇒ 落到文本匹配(`locateWith`,与 `anchor` 同一份引擎)。
770
+ //
771
+ // 顺序上刻意在 `target` 之前:`intent` 与其余地址字段**互斥**(`PATCH_INTENT_CONFLICT`,
772
+ // 由 `planHunks` 判),所以两者同时到达这里是不可能的;放在最前只是把"intent 自成一层"写明。
773
+ if (typeof request.intent === 'string' && request.intent.trim().length > 0) {
774
+ const intent = request.intent.trim()
775
+ const near = request.near ?? null
776
+ const label = clipReceiptLabel(filePath === null ? 'this file' : filePath, RECEIPT_LABEL_CHARS)
777
+ /** 跨度覆盖不了符号体时的拒绝——与 `target=` 那一支**同一句话、同一个码**(不是第二套说法)。 */
778
+ const spanIncomplete = (hit) => ({
779
+ ok: false,
780
+ code: 'LOCATOR_SPAN_INCOMPLETE',
781
+ message: `cannot address "${hit.name}" by intent in ${label}: the span does not cover the body — ${hit.spanNote}; address it with lines= or line_cue= instead`,
782
+ hint: directoryHint(source, lang),
783
+ })
784
+ if (lang !== null) {
785
+ const index = indexStructure(source, lang)
786
+ /**
787
+ * 精确/限定符号:先**不带 near** 解析,拿到唯一命中或完整候选。
788
+ * 带 near 交给 `resolveTarget` 会用“起点距离”,与 intent 档的“跨度包含 near 记 0”冲突,
789
+ * 实测会把 near 落在第一个长函数体内的情况选到第二个同名函数上。歧义时这里用
790
+ * `nearestSymbolBySpan` 按跨度收敛;解析不了再落到子串匹配。
791
+ */
792
+ let exact = null
793
+ try {
794
+ exact = resolveTarget(index, intent, null)
795
+ } catch (error) {
796
+ if (error.code === 'STRUCT_AMBIGUOUS') {
797
+ const candidates = Array.isArray(error.candidates) ? error.candidates : []
798
+ if (near !== null && near !== undefined) {
799
+ const chosen = nearestSymbolBySpan(candidates, near)
800
+ if (chosen !== null) {
801
+ if (chosen.spanComplete === false) return spanIncomplete(chosen)
802
+ return done(chosen.startLine, chosen.endLine, 'intent~symbol', true)
803
+ }
804
+ }
805
+ return failure('LOCATOR_INTENT_AMBIGUOUS', `intent ${JSON.stringify(intent)}: ${error.message}`, candidates)
806
+ }
807
+ // STRUCT_NOT_FOUND / 其它:符号名没猜中不是终点,落到子串匹配(下面)。
808
+ }
809
+ if (exact !== null) {
810
+ if (exact.spanComplete === false) return spanIncomplete(exact)
811
+ const shared = sharedLineContent(source, index, exact, lang)
812
+ if (shared !== null) return sharedLineRefusal(exact, shared, 'intent')
813
+ return done(exact.startLine, exact.endLine, 'intent~symbol')
814
+ }
815
+ // 用原始符号表而不是 `listSymbols` 投影:整行区间是否越界要看 token,投影里没有。
816
+ const symbols = index.symbols
817
+ let matches = symbols.filter((s) => s.name.includes(intent))
818
+ if (matches.length === 0) {
819
+ const lower = intent.toLowerCase()
820
+ matches = symbols.filter((s) => s.name.toLowerCase().includes(lower))
821
+ }
822
+ if (matches.length === 1) {
823
+ const only = matches[0]
824
+ if (only.spanComplete === false) return spanIncomplete(only)
825
+ const shared = sharedLineContent(source, index, only, lang)
826
+ if (shared !== null) return sharedLineRefusal(only, shared, 'intent')
827
+ return done(only.startLine, only.endLine, 'intent~symbol')
828
+ }
829
+ if (matches.length > 1) {
830
+ if (near !== null && near !== undefined) {
831
+ const chosen = nearestSymbolBySpan(matches, near)
832
+ if (chosen !== null) {
833
+ if (chosen.spanComplete === false) return spanIncomplete(chosen)
834
+ const shared = sharedLineContent(source, index, chosen, lang)
835
+ if (shared !== null) return sharedLineRefusal(chosen, shared, 'intent')
836
+ return done(chosen.startLine, chosen.endLine, 'intent~symbol', true)
837
+ }
838
+ }
839
+ const why =
840
+ near === null || near === undefined
841
+ ? `intent ${JSON.stringify(intent)} matched ${matches.length} symbols — pick one with target=<name>, or add near=<line>`
842
+ : `intent ${JSON.stringify(intent)} matched ${matches.length} symbols and near=${near} did not single one out`
843
+ return failure(
844
+ 'LOCATOR_INTENT_AMBIGUOUS',
845
+ why,
846
+ matches.map((s) => ({
847
+ name: s.name,
848
+ kind: s.kind,
849
+ startLine: s.startLine,
850
+ endLine: s.endLine,
851
+ enclosing: s.enclosing,
852
+ })),
853
+ )
854
+ }
855
+ }
856
+ // ── ③ 文本兜底:与 `anchor` 同一份引擎(`locateWith`);tier 里带上它命中的那一档
857
+ try {
858
+ const loc = locateWith(indexFile(source), intent, near)
859
+ return done(loc.start + 1, loc.end, `intent~text~${loc.tier}`, loc.byNear === true)
860
+ } catch (error) {
861
+ if (error.code === 'PATCH_AMBIGUOUS') {
862
+ /**
863
+ * 行候选统一成 `{line, text}`(`failure` 只认这一形状才会印出行号与原文):
864
+ * `locateWith` 抛的是 `number[]`,直接递下去会印成 `line undefined`。
865
+ */
866
+ const listed = (Array.isArray(error.candidates) ? error.candidates : [])
867
+ .filter((n) => typeof n === 'number' && n >= 1 && n <= lines.length)
868
+ .map((n) => ({ line: n, text: lines[n - 1].slice(0, 80) }))
869
+ return failure('LOCATOR_INTENT_AMBIGUOUS', `intent ${JSON.stringify(intent)}: ${error.message}`, listed)
870
+ }
871
+ if (error.code === 'PATCH_NOT_FOUND' || error.code === 'PATCH_EMPTY_ANCHOR') {
872
+ return {
873
+ ok: false,
874
+ code: 'LOCATOR_INTENT_NOT_FOUND',
875
+ message: `no symbol or text matches intent ${JSON.stringify(intent)} in ${label}; try target=<name>, lines=, or line_cue=`,
876
+ // 符号目录是实测支撑的恢复路径(与 anchor 失败同一条):只有认识这门语言时才给得出。
877
+ ...(lang === null ? {} : { hint: directoryHint(source, lang) }),
878
+ }
879
+ }
880
+ return failure(error.code ?? 'LOCATOR_FAILED', error.message, error.candidates)
881
+ }
882
+ }
883
+
691
884
  // ── 第 1 层:符号身份。不读文本,因此对内容记错免疫。
692
885
  if (typeof request.target === 'string' && request.target.trim().length > 0) {
693
886
  if (lang === null) {
694
887
  return { ok: false, code: 'LOCATOR_NO_LANGUAGE', message: 'target= needs a recognized file extension' }
695
888
  }
889
+ /**
890
+ * lone CR:符号索引按 `\n` 数行,老式 Mac 文件整份是“一行”,`target` 落成 1-1 后
891
+ * 会把整个文件替换成一个符号。行号地址在这种情况下没有第二个行模型可校验,
892
+ * 必须在定位前拒绝,而不是用错误的行号去改。
893
+ */
894
+ if (hasLoneCarriageReturn(source)) {
895
+ return failure(
896
+ 'LOCATOR_LONE_CR',
897
+ `cannot address "${request.target}" by target in ${clipReceiptLabel(filePath, RECEIPT_LABEL_CHARS)}: this file uses lone CR (\\r) line endings, so line/span addresses point at different bytes than a \\n line model; use old= (verbatim fragment) or handle=, or rewrite the whole file explicitly — nothing was written`,
898
+ )
899
+ }
696
900
  try {
697
- const hit = resolveTarget(indexStructure(source, lang), request.target, request.near ?? null)
901
+ const index = indexStructure(source, lang)
902
+ const hit = resolveTarget(index, request.target, request.near ?? null)
698
903
  /**
699
904
  * 跨度覆盖不了符号体时必须**拒绝**,不能退化成"只有签名行"。
700
905
  *
@@ -710,6 +915,25 @@ export function locate(source, request = {}) {
710
915
  hint: directoryHint(source, lang),
711
916
  }
712
917
  }
918
+ /**
919
+ * 同一行还有**同级符号** ⇒ `target` 落到的整行区间会连它一起删掉。
920
+ * 压缩代码里两个顶层函数同为 1-1 是实测形态;名字命中不代表“只改这个名字”。
921
+ */
922
+ const siblings = siblingSymbolsOnSameSpan(index, hit)
923
+ if (siblings.length > 0) {
924
+ const names = siblings.map((s) => `${s.kind} ${s.name} (line ${s.startLine})`).join(', ')
925
+ return {
926
+ ...failure(
927
+ 'LOCATOR_LINE_SHARED',
928
+ `cannot address "${hit.name}" by target in ${clipReceiptLabel(filePath, RECEIPT_LABEL_CHARS)}: line ${hit.startLine} also holds ${names}, so replacing the symbol's whole-line span would delete them; use old= with a unique fragment, handle=, or rewrite the whole file explicitly — nothing was written`,
929
+ siblings.map((s) => ({ name: s.name, kind: s.kind, enclosing: s.enclosing, startLine: s.startLine, endLine: s.endLine })),
930
+ ),
931
+ hint: directoryHint(source, lang),
932
+ }
933
+ }
934
+ /** 兄弟符号之外,行内还可能有 `import` / 指令 / 调用语句:同一个判据,看字节。 */
935
+ const shared = sharedLineContent(source, index, hit, lang)
936
+ if (shared !== null) return sharedLineRefusal(hit, shared, 'target')
713
937
  return done(hit.startLine, hit.endLine, 'symbol')
714
938
  } catch (error) {
715
939
  return {
@@ -723,6 +947,17 @@ export function locate(source, request = {}) {
723
947
  if (request.lines !== undefined && request.lines !== null) {
724
948
  const start = request.lines.start
725
949
  const end = request.lines.end ?? request.lines.start
950
+ /**
951
+ * 只有**显式整份写**(`aura_basetools_write` 的内部 wholeFile 请求)能越过 lone CR 闸:
952
+ * 它替换的是整个文件的字节,不依赖行号模型。普通 `lines=[1,1]` 仍拒绝。
953
+ */
954
+ const wholeFile = request.allowLoneCr === true && start === 1 && end === lines.length
955
+ if (!wholeFile && hasLoneCarriageReturn(source)) {
956
+ return failure(
957
+ 'LOCATOR_LONE_CR',
958
+ `cannot address lines ${start}-${end} in ${clipReceiptLabel(filePath, RECEIPT_LABEL_CHARS)}: this file uses lone CR (\\r) line endings, so line numbers do not describe the bytes a \\n line model sees; use old= (verbatim fragment) or handle=, or rewrite the whole file explicitly — nothing was written`,
959
+ )
960
+ }
726
961
  if (!Number.isInteger(start) || !Number.isInteger(end)) {
727
962
  return { ok: false, code: 'LOCATOR_BAD_LINES', message: 'lines.start / lines.end must be integers' }
728
963
  }
@@ -785,15 +1020,15 @@ export function locate(source, request = {}) {
785
1020
  return {
786
1021
  ...failure(error.code ?? 'LOCATOR_FAILED', error.message, error.candidates),
787
1022
  // 锚点失败时给出符号目录——这是实测支撑的恢复路径。
788
- // 注:`hint` 走的是结构化字段,只有把它拼进 message 的调用方(如 aura_edit
789
- // 的 symbolHint)才真能让模型看到;aura_read 直接透传 message,所以真正
1023
+ // 注:`hint` 走的是结构化字段,只有把它拼进 message 的调用方(如 aura_basetools_edit
1024
+ // 的 symbolHint)才真能让模型看到;aura_basetools_read 直接透传 message,所以真正
790
1025
  // 抵达模型的是上面那条 message。
791
1026
  ...(lang === null ? {} : { hint: directoryHint(source, lang) }),
792
1027
  }
793
1028
  }
794
1029
  }
795
1030
 
796
- return { ok: false, code: 'LOCATOR_NO_REQUEST', message: 'provide one of: target, lines, lineCue, anchor' }
1031
+ return { ok: false, code: 'LOCATOR_NO_REQUEST', message: 'provide one of: intent, target, lines, lineCue, anchor' }
797
1032
  }
798
1033
 
799
1034
  /**
@@ -809,10 +1044,154 @@ export function locate(source, request = {}) {
809
1044
  * @returns 追加到错误消息后的说明;不含单 `\r` 时为空串
810
1045
  */
811
1046
  function loneCarriageReturnNote(source) {
812
- if (!/\r(?!\n)/.test(source)) return ''
1047
+ if (!hasLoneCarriageReturn(source)) return ''
813
1048
  return ' — this file uses lone CR (\\r) line endings, which are not treated as line breaks here (the symbol index counts lines by \\n, so a second line model would point at different bytes); address it with line_cue/anchor, or replace the whole file with lines=[1,1]'
814
1049
  }
815
1050
 
1051
+ /** 声明前缀里本来就属于声明的标点(指针/引用/泛型/命名空间/可空修饰)。 */
1052
+ const DECL_PREFIX_PUNCT = new Set(['*', '&', '<', '>', ',', '::', '?', ':', '~', '.'])
1053
+
1054
+ /** 允许“包住声明”的开括号:Lisp 族的 `(defn …)`。`{` 不算(JS/C 的块不是声明本身)。 */
1055
+ const FORM_OPENERS = new Map([['(', ')'], ['[', ']']])
1056
+
1057
+ /**
1058
+ * 整行替换会把声明之外的内容一起删掉的那些行。
1059
+ *
1060
+ * `siblingSymbolsOnSameSpan` 只看得见符号表里的兄弟;`import` / 指令 / 调用语句
1061
+ * 根本不是符号,却同样会被整行替换删掉(实测:`import fs from 'fs'; function alpha()`
1062
+ * 上 `target=alpha` 会把 import 一起删掉)。判据回到字节:
1063
+ * · 声明起点之前:只允许空白与**包住这个声明**的开括号(Lisp 族的 `(defn …)`);
1064
+ * · 声明终点之后:只允许空白、分号、注释与包住这个声明的闭括号。
1065
+ * 其余任何 token(`import` / `return` / 另一条语句)都说明这一个行区间不止这个声明。
1066
+ *
1067
+ * @returns `{line, side:'before'|'after'|'header', text}`,或 null(这一行只属于这个声明)
1068
+ */
1069
+ function sharedLineContent(source, index, hit, lang) {
1070
+ const declStartTok = hit.declStartTok ?? hit.startTok
1071
+ const startToken = index.tokens[declStartTok]
1072
+ const endToken = index.tokens[hit.endTok]
1073
+ if (startToken === undefined || endToken === undefined) return null
1074
+ const startLine = index.lineAt(startToken.start)
1075
+ /**
1076
+ * C 家族:`$ident*` 吃不掉关键字返回类型,声明起点可能落在名字上(见 struct-core 的
1077
+ * `declStartUnknown`)。类型回到上一行时,行区间会把上一行留成悬空前缀——拒绝,不静默改。
1078
+ * 指令行(`#include`)与注解行(`@Override`)本来就不是声明头,不参与这条判据。
1079
+ */
1080
+ if (hit.declStartUnknown === true) {
1081
+ const prev = index.tokens[(hit.declStartTok ?? hit.startTok) - 1]
1082
+ if (prev !== undefined) {
1083
+ const prevLine = index.lineAt(prev.end)
1084
+ if (prevLine === startLine - 1 && ![';', '{', '}'].includes(prev.value)) {
1085
+ const prevLineStart = source.lastIndexOf('\n', prev.start - 1) + 1
1086
+ const prevLineEnd = source.indexOf('\n', prev.end)
1087
+ const prevLineText = source.slice(prevLineStart, prevLineEnd === -1 ? source.length : prevLineEnd)
1088
+ if (!/^\s*[#@]/.test(prevLineText)) {
1089
+ return { line: prevLine, side: 'header', text: prevLineText.trim().slice(0, 80) }
1090
+ }
1091
+ }
1092
+ }
1093
+ }
1094
+ /**
1095
+ * 声明起点之前、同一行里的 token。只有两类可以留:
1096
+ * · **类型前缀**——仅当 `declStartUnknown`(C 家族把返回类型漏在声明起点之前);
1097
+ * · **包住声明的开括号**(Lisp 族):它的闭括号会在后缀里出现。
1098
+ * 其余任何 token(`;` / `{` / `=` / 字符串 / `@` / 别的语句)都说明这一行不止这个声明。
1099
+ */
1100
+ const allowTypePrefix = hit.declStartUnknown === true
1101
+ const enclosing = new Map()
1102
+ for (let k = declStartTok - 1; k >= 0; k -= 1) {
1103
+ const token = index.tokens[k]
1104
+ if (index.lineAt(token.start) !== startLine) break
1105
+ if (allowTypePrefix && token.type === 'ident') continue
1106
+ if (allowTypePrefix && token.type === 'punct' && DECL_PREFIX_PUNCT.has(token.value)) continue
1107
+ if (token.type === 'punct' && FORM_OPENERS.has(token.value)) {
1108
+ // `close > endTok` 或 `close === endTok`:Lisp 的收尾 `)` 本身常是跨度的最后一个 token
1109
+ // (`(defn alpha [] 1)` 的 endTok 就是那个 `)`),开括号却仍在跨度之前。
1110
+ const close = index.pairs.get(k)
1111
+ if (close !== undefined && close >= hit.endTok) {
1112
+ const closer = FORM_OPENERS.get(token.value)
1113
+ enclosing.set(closer, (enclosing.get(closer) ?? 0) + 1)
1114
+ continue
1115
+ }
1116
+ }
1117
+ const lineStart = source.lastIndexOf('\n', startToken.start - 1) + 1
1118
+ return { line: startLine, side: 'before', text: source.slice(lineStart, startToken.start).trim().slice(0, 80) }
1119
+ }
1120
+ const endLine = index.lineAt(endToken.end)
1121
+ const lineEnd = source.indexOf('\n', endToken.end)
1122
+ const after = source.slice(endToken.end, lineEnd === -1 ? source.length : lineEnd)
1123
+ const rest = trailingForeign(after, lang, enclosing)
1124
+ if (rest !== '') return { line: endLine, side: 'after', text: rest.trim().slice(0, 80) }
1125
+ return null
1126
+ }
1127
+
1128
+ /**
1129
+ * 声明终点之后同一行里还剩什么:空白、分号、注释、以及**包住声明的**闭括号不算别的代码。
1130
+ * 闭括号只在 `enclosing` 计数里出现(由前缀里那些开括号登记),所以 `function inner(){} }`
1131
+ * 这种“外层块的 `}` 落在同一行”不会被当成收尾。
1132
+ *
1133
+ * @returns 剩下的非平凡文本(空串 = 这一行到此为止只属于这个声明)
1134
+ */
1135
+ function trailingForeign(text, lang, enclosing) {
1136
+ let rest = text
1137
+ const lineComments = lang?.lineComments ?? []
1138
+ const blockComments = lang?.blockComments ?? []
1139
+ for (;;) {
1140
+ rest = rest.replace(/^\s+/, '')
1141
+ if (rest === '') return ''
1142
+ if (lineComments.some((marker) => rest.startsWith(marker))) return ''
1143
+ const block = blockComments.find(([open]) => rest.startsWith(open))
1144
+ if (block !== undefined) {
1145
+ const close = rest.indexOf(block[1], block[0].length)
1146
+ if (close === -1) return rest
1147
+ rest = rest.slice(close + block[1].length)
1148
+ continue
1149
+ }
1150
+ // 分号单独处理:JS 家族是语句分隔符,Lisp 族已经在 lineComments 里提前返回。
1151
+ if (rest.startsWith(';')) {
1152
+ rest = rest.slice(1)
1153
+ continue
1154
+ }
1155
+ const closer = rest[0]
1156
+ const count = enclosing.get(closer) ?? 0
1157
+ if (count > 0) {
1158
+ enclosing.set(closer, count - 1)
1159
+ rest = rest.slice(1)
1160
+ continue
1161
+ }
1162
+ return rest
1163
+ }
1164
+ }
1165
+
1166
+ /** 文件是否含**不跟 LF** 的 CR(老式 Mac 行尾)。只扫一次、不建第二套行模型。 */
1167
+ function hasLoneCarriageReturn(source) {
1168
+ return /\r(?!\n)/.test(source)
1169
+ }
1170
+
1171
+ /**
1172
+ * 同一行里的**同级符号**(会随整行替换一起消失的那些)。
1173
+ *
1174
+ * 符号投影只有行号、没有列;`target` 最终落到 `[startLine, endLine]` 整行区间。
1175
+ * 压缩代码里两个顶层函数同为 1-1 时,`target=alpha` 会静默删掉 `beta` —— 这是
1176
+ * “名字命中但实际替换整行”的最坏形态。这里只比较**同一 enclosing** 的符号:
1177
+ * 函数体内的局部变量(enclosing 指向函数)不算,否则每个单行函数都会被自己的变量误伤。
1178
+ */
1179
+ function siblingSymbolsOnSameSpan(index, hit) {
1180
+ const scope = hit.enclosing ?? null
1181
+ return index.symbols.filter((symbol) => {
1182
+ if ((symbol.enclosing ?? null) !== scope) return false
1183
+ if (
1184
+ symbol.name === hit.name &&
1185
+ symbol.kind === hit.kind &&
1186
+ symbol.startLine === hit.startLine &&
1187
+ symbol.endLine === hit.endLine
1188
+ ) {
1189
+ return false
1190
+ }
1191
+ return symbol.startLine <= hit.endLine && symbol.endLine >= hit.startLine
1192
+ })
1193
+ }
1194
+
816
1195
  /**
817
1196
  * 符号目录:失败时给调用方一条可执行的恢复路径。
818
1197
  *