mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 根因:记忆系统已迁移,前端没跟上
`GraphData()` 同时返回两套数据,而前端**只读旧的**:
nodes/edges ← 旧表 entities/relations(2026-10-04 停写)
memory_blocks/block_edges ← 块/边体系(真实活跃的记忆)
生产实测:`entities` 停更在 2026-10-03(1294 条),而 `memory_blocks`
同期从 1294 涨到 3187。写入函数 `upsertEntity` 已成**全仓零调用的孤儿**。
⇒ 星图结构性看不到任何新记忆。
(另修的同类问题:`starmapDirty` 只写不读且未声明,赋值成隐式全局 ⇒
即使后端发事件,新块也永远不会出现在图上。见下「判据」。)
## 数据源切换
新增 `smAdaptGraph()` 把块/边归一化为星图节点/边(字段与旧格式一致,
下游建图/布局/图例/脉冲无需各自判源):
- `mention_count` 回填为**度数**(块表无此列,而它驱动节点大小/标签优先级)
- 只画 `status` 为 active/空 的边(deleted/merged 的边留着会让失效关系看着还在)
- 端点缺失的边丢弃(画不出,且会让度数统计偏大)
## 视觉(按「全部标名 + 灰调 + 悬浮提亮 + 查询提亮 + 三种变化动画」)
**全部节点名**:新增标签覆盖层 —— **一个** 2D canvas 画全部名字,而非
每节点一张 CanvasTexture(原实现 1151 张 ≈ 72MB 显存,也是它当初被删的原因)。
三级可读性:活动/悬浮=实色+底块,其余=低透明当底纹;**同屏文本去重**
(3178 块里大量同文本块,不去重会叠成十几层同样的字)。
仅在相机或脉冲集合变化时重绘。
**灰色调**:`smGreyFor()` 全图灰阶,**亮度按度数**(亮处即记忆稠密处)。
原按类型上色对真实数据只用得上一种颜色(99% 同一类型)。
**悬浮提亮**:发光 +1.4 + 放大 1.25×(只放大不亮在淡底上不明显,只亮不放大
又看不出是哪颗)。
**查询提亮**:`memory_access` → `starmapPulseByIDs()`,按**真实块 ID** 精确命中。
**三种变化动画**(三种是**排他状态机**,不与可叠加的活动 pulse 混用):
| 动画 | 曲线 | 时长 |
|---|---|---|
| 插入生长 | easeOutBack 过冲(从 0.05 起,从 0 起看不出「长」) | 1800ms |
| 删除消散 | 收缩 + 变暗 + 球面均匀粒子飞散(减速淡出) | 1200ms |
| 合入 | 源消散 → **停顿 120ms** → 目标重生 | 1200+120+900ms |
合入刻意分两段且中间停顿:同时放会读成「一个变小一个变大」,
先看不到源、再看到目标冒出,「合入」的因果才成立。
粒子用**单个** Points 承载(上限 600),而非每粒子一个 mesh ——
批量删除(Purge 上百块)时后者会瞬间建上千对象。
消散完成必须真 `scene.remove()`,否则 scale=0 的不可见 mesh 仍可被鼠标拾中。
**顺带**:
- 补选中详情面板(`starmapSelected` 此前**只赋值、无人读取**,点击节点
没有任何信息反馈 —— 「可读性差」的核心成因之一)
- 布局半径按 √n 自适应 + 相机初始距离自适应(3178 节点挤在旧的固定
15–80 环带里会糊成一片)
- 图例改为说明**视觉编码**(大小/亮度/活动/三种动画),不再列不存在的类型色
- `starmapDirty` 消费点加 30s 节流(`/memory/graph` 实测 3.3MB,
密集写入时每 10s 重拉是纯浪费)
## 判据
- `graph_source_test.go`:钉住后端**必须下发 memory_blocks**
(前端星图的真实数据源),防未来又切回停写的 nodes
- `TestDashboardJS_NoWriteOnlyGlobals`(新增静态判据):**只写不读的全局布尔标志**
⇒ 这类「置了标志无人消费」是静默失效,真跑 node 也抓不到(隐式全局赋值合法)。
该判据写出来就当场抓到 `starmapDirty`,并经变异验证有效
(删掉消费点 → 判据点名报错)
- `node --check` + webui 全部判据绿
418 lines
17 KiB
Go
418 lines
17 KiB
Go
package webui
|
||
|
||
import (
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// dashboard.js 的**作用域**判据:所有被跨函数调用的 render* 必须是顶层声明。
|
||
//
|
||
// ## 要判的是什么(2026-10-05 生产实测白屏根因)
|
||
//
|
||
// Uncaught (in promise) ReferenceError: renderChatUsage is not defined
|
||
// at starmapFetchBlock (dashboard.js:3830) ← 顶层函数
|
||
// at async Promise.all (index 2)
|
||
// at async renderAll (dashboard.js:3878)
|
||
//
|
||
// renderChatUsage 定义在 dashboard.js:4475,**缩进是一个 tab**,
|
||
// 位置在 connectSSE()(4342 行)内部 ⇒ 它只在 connectSSE 执行期间处于作用域内。
|
||
//
|
||
// 而 starmapFetchBlock 是**顶层函数**,在 renderAll 里与其它 fetch 并发跑,
|
||
// 那时 connectSSE 早已返回 ⇒ renderChatUsage 是 undefined ⇒ ReferenceError。
|
||
//
|
||
// ★ 为什么这会导致「大量页面白屏」而不是「少一个数字」:
|
||
//
|
||
// renderAll 里 `await Promise.all(needs.map(starmapFetchBlock))`
|
||
// **没有 try/catch**,而 fetch 列表里 overview/plugins/kernel 三个页签
|
||
// 都含 "kernel" ⇒ 任何一页触发它,Promise.all 整体 reject,
|
||
// **后面的 spec.render.forEach 根本不执行** ⇒ 整页空白。
|
||
//
|
||
// renderAll 明明给 render 步骤写了 `typeof window[fn] === "function"` 守卫、
|
||
// 也给星图写了 try/catch,唯独漏了 fetch 这一步 —— 而它跑在 render 之前。
|
||
//
|
||
// ★ 为什么这类 bug 能活下来:
|
||
// - Go 侧测试只验 dashboard.html/js/css 的**组装**(TestDashboardAssetsSplit),
|
||
// 不验 JS 语义 ⇒ 编译、vet、单测全绿。
|
||
// - 它只在**运行期、且 state.kernel.usage 存在**(即 /kernel 返回了用量)时触发。
|
||
// 新装实例 usage 为 0/null 时不炸 ⇒ 装完看着好好的,跑几天后才白屏。
|
||
//
|
||
// 判据做法:用 node 在沙箱里把 dashboard.js 跑一遍,抓未捕获异常。
|
||
// node 不在时 t.Skip(CI 里 GO 环境可能没有 node)。
|
||
|
||
// dashboardJSSource 取出待测脚本文本(测试内复用以免重复读 embed)。
|
||
func dashboardJSSource(t *testing.T) string {
|
||
t.Helper()
|
||
b, err := dashboardFS.ReadFile("dashboard.js")
|
||
if err != nil {
|
||
t.Fatalf("读 dashboard.js: %v", err)
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// TestDashboardJS_HasNoReferenceErrorInStrictMode 用 node 跑真脚本抓 ReferenceError。
|
||
//
|
||
// ★ 为什么必须真跑而不是 grep「函数名出现过几次」:
|
||
//
|
||
// 文本上 renderChatUsage 出现了 2 次(定义 + 调用),grep 查不出作用域问题。
|
||
// 作用域只能在真执行时暴露 —— 这是本仓反复记的「测真实行为而非形态」。
|
||
func TestDashboardJS_HasNoReferenceErrorInStrictMode(t *testing.T) {
|
||
if _, err := exec.LookPath("node"); err != nil {
|
||
t.Skip("无 node,跳过 JS 语义判据")
|
||
}
|
||
js := dashboardJSSource(t)
|
||
// 触发 kernel 分支里的 renderChatUsage 调用:造一个带 usage 的 /kernel 响应,
|
||
// 然后调 starmapFetchBlock("kernel")。
|
||
//
|
||
// ★★ 桩必须建在 dashboard.js **之前**(否则整个脚本不执行,判据变成「测不到」):
|
||
// ① 文件顶部有立即执行的 IIFE(initAppearance / 主题应用),
|
||
// 它们访问 localStorage / document.body / window.innerWidth / matchMedia。
|
||
// ② 文件末尾有 setInterval(renderAll, 15000) 与 setInterval(sync…, 30000),
|
||
// **不接管 setInterval 的话 node 永不退出** —— 实测表现为
|
||
// exit 0 且 stdout 全空,看起来像「脚本没报错」,实则根本没跑到断言。
|
||
// 这条踩坑本身就是判据设计的坑:静默 hang 会被误读成通过。
|
||
harness := `
|
||
const __caught = [];
|
||
process.on('uncaughtException', (e) => { __caught.push(String(e)); });
|
||
process.on('unhandledRejection', (e) => { __caught.push('Rejection: ' + String(e)); });
|
||
const __store = {};
|
||
globalThis.localStorage = {
|
||
getItem: (k) => (k in __store ? __store[k] : null),
|
||
setItem: (k, v) => { __store[k] = String(v); },
|
||
removeItem: (k) => { delete __store[k]; },
|
||
};
|
||
globalThis.matchMedia = () => ({ matches: false, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){} });
|
||
globalThis.innerWidth = 1400;
|
||
globalThis.innerHeight = 900;
|
||
globalThis.addEventListener = () => {};
|
||
globalThis.requestAnimationFrame = (f) => setTimeout(f, 16);
|
||
globalThis.cancelAnimationFrame = () => {};
|
||
// ★ 接管定时器:否则永不退出(见上)。
|
||
globalThis.setInterval = () => 0;
|
||
globalThis.clearInterval = () => {};
|
||
const __mkEl = () => ({
|
||
style: {}, dataset: {}, children: [],
|
||
classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||
setAttribute(){}, getAttribute(){ return null; },
|
||
appendChild(c){ this.children.push(c); return c; },
|
||
removeChild(){}, insertAdjacentHTML(){}, remove(){}, addEventListener(){},
|
||
removeEventListener(){}, querySelector(){ return null; }, querySelectorAll(){ return []; },
|
||
getBoundingClientRect(){ return { width: 0, height: 0, left: 0, top: 0 }; },
|
||
focus(){}, click(){}, closest(){ return null; },
|
||
});
|
||
globalThis.document = {
|
||
documentElement: { setAttribute(){}, classList:{ add(){}, remove(){}, toggle(){} }, style:{ setProperty(){} } },
|
||
head: __mkEl(), body: __mkEl(),
|
||
createElement: __mkEl, createTextNode: (t) => ({ textContent: t }),
|
||
// ★ getElementById 必须返回**可用元素**而不是 null:
|
||
// dashboard.js 末尾有顶层调用 renderConfigDisabled()(7129 行),
|
||
// 它对 getElementById 的结果写 .innerHTML。返回 null ⇒ TypeError
|
||
// ⇒ node 在跑到断言之前就退出,**stdout 全空、exit 0**,
|
||
// 看起来像「没报错」,实则一条断言都没执行 —— 静默假绿。
|
||
getElementById: () => __mkEl(),
|
||
querySelector: () => __mkEl(), querySelectorAll: () => [],
|
||
getElementsByClassName: () => [], getElementsByTagName: () => [],
|
||
addEventListener(){}, removeEventListener(){}, cookie: '',
|
||
};
|
||
globalThis.window = globalThis;
|
||
globalThis.__ = (zh) => zh;
|
||
globalThis.navigator = { language: 'zh-CN', userAgent: 'node', clipboard: { writeText: async () => {} } };
|
||
globalThis.location = { href: 'http://x/', origin: 'http://x', protocol: 'http:', hostname: 'x', reload(){}, assign(){} };
|
||
globalThis.history = { replaceState(){}, pushState(){} };
|
||
globalThis.EventSource = class { constructor(){} addEventListener(){} close(){} };
|
||
` + js + `
|
||
// ① 让 api() 直接返回带 usage 的 kernel,避开网络。
|
||
// ★ content-type 必须是 application/json:api()(dashboard.js:414)先看
|
||
// r.headers.get("content-type"),含 "json" 才走 r.json();
|
||
// 否则走 r.text() ⇒ state.kernel 变成字符串 ⇒ kernel 分支的
|
||
// "if (state.kernel && state.kernel.usage)" 为假 ⇒ 压根走不到那一行,
|
||
// 判据变成「测不到」(实测 state.kernel = "")。
|
||
globalThis.fetch = async () => ({
|
||
ok: true, status: 200,
|
||
json: async () => ({ usage: { total: 42, prompt: 10, completion: 32 } }),
|
||
text: async () => '',
|
||
headers: { get: (k) => (String(k).toLowerCase() === 'content-type' ? 'application/json' : null) },
|
||
});
|
||
(async () => {
|
||
try {
|
||
await starmapFetchBlock('kernel');
|
||
} catch (e) {
|
||
console.log('THROWN:' + String((e && e.stack) ? e.stack.split('\\n')[0] : e));
|
||
process.exitCode = 3;
|
||
return;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 60));
|
||
if (__caught.length) {
|
||
console.log('CAUGHT:' + JSON.stringify(__caught));
|
||
process.exitCode = 3;
|
||
} else {
|
||
console.log('OK');
|
||
}
|
||
})();
|
||
`
|
||
// ★ 必须写临时文件而不是 node -e:dashboard.js 有 500KB+,
|
||
// 内联进命令行会撞 ARG_MAX(实测 "argument list too long")。
|
||
tmp := filepath.Join(t.TempDir(), "harness.js")
|
||
if err := os.WriteFile(tmp, []byte(harness), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out, err := exec.Command("node", tmp).CombinedOutput()
|
||
o := string(out)
|
||
t.Logf("node 输出: %s", strings.TrimSpace(o))
|
||
// ★ 任何形式的运行期异常都算失败,包括 harness 自己报出的 THROWN:。
|
||
//
|
||
// 先前这里只认 "CAUGHT:",而 harness 是用 try/catch 包住
|
||
// starmapFetchBlock 并打印 "THROWN:" 的 ⇒ 真实缺陷被判成
|
||
// 「非本次目标」而 Skip,**又是一次假绿**。
|
||
// 判据自己的报告格式不能成为它的免罪符。
|
||
if strings.Contains(o, "THROWN:") || strings.Contains(o, "CAUGHT:") {
|
||
t.Fatalf("dashboard.js 运行期抛错(页面会白屏):\n%s", strings.TrimSpace(o))
|
||
}
|
||
if err != nil {
|
||
if strings.Contains(o, "OK") {
|
||
return
|
||
}
|
||
t.Fatalf("node 执行失败(判据自身的问题,不能 Skip ——\n"+
|
||
"Skip 会把「测不到」伪装成「通过」):%v\n%s", err, strings.TrimSpace(o))
|
||
}
|
||
if !strings.Contains(o, "OK") {
|
||
t.Fatalf("node 跑完但没到断言点(又是静默假绿):\n%s", strings.TrimSpace(o))
|
||
}
|
||
}
|
||
|
||
// TestDashboardJS_RenderHelpersAreTopLevel 静态钉死作用域:
|
||
// 凡出现在 TABS[].render 或被 starmapFetchBlock 调用的 render*,缩进必须是 0。
|
||
func TestDashboardJS_RenderHelpersAreTopLevel(t *testing.T) {
|
||
js := dashboardJSSource(t)
|
||
lines := strings.Split(js, "\n")
|
||
|
||
// 找出所有形如 `\t*function NAME(` 的声明,记录缩进深度。
|
||
depth := map[string]int{}
|
||
for _, l := range lines {
|
||
t2 := strings.TrimLeft(l, "\t")
|
||
if !strings.HasPrefix(t2, "function ") && !strings.HasPrefix(t2, "async function ") {
|
||
continue
|
||
}
|
||
rest := t2[strings.Index(t2, "function ")+len("function "):]
|
||
name := strings.TrimSpace(strings.SplitN(rest, "(", 2)[0])
|
||
name = strings.TrimPrefix(name, "async ")
|
||
depth[name] = len(l) - len(t2) // tab 数
|
||
}
|
||
|
||
// 这些必须顶层:TABS 里声明的 render 名 + starmapFetchBlock 里直接调用的。
|
||
mustTop := map[string]string{
|
||
"renderChatUsage": "starmapFetchBlock 的 kernel 分支调用它(dashboard.js:3830)",
|
||
"renderOverview": "TABS.overview.render",
|
||
"renderPlugins": "TABS.plugins.render",
|
||
"renderKernel": "TABS.kernel.render",
|
||
"renderChat": "TABS.chat.render",
|
||
"renderAdapters": "TABS.adapters.render",
|
||
}
|
||
for name, why := range mustTop {
|
||
d, ok := depth[name]
|
||
if !ok {
|
||
t.Errorf("%s 未找到声明(%s)", name, why)
|
||
continue
|
||
}
|
||
if d != 0 {
|
||
// 报告行号
|
||
line := 0
|
||
for i, l := range lines {
|
||
t2 := strings.TrimLeft(l, "\t")
|
||
if strings.HasPrefix(t2, "function "+name+"(") ||
|
||
strings.HasPrefix(t2, "async function "+name+"(") {
|
||
line = i + 1
|
||
break
|
||
}
|
||
}
|
||
t.Errorf("%s 声明在缩进 %d 处(dashboard.js:%d),必须顶层(缩进 0)——"+
|
||
"否则顶层函数调用它时是 undefined:%s", name, d, line, why)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestRenderAll_GuardsEveryStep 钉住 renderAll 的每一步都有兜底。
|
||
//
|
||
// ★ 这是白屏能扩大的**结构性原因**:fetch 那一步裸奔,
|
||
//
|
||
// 一个 ReferenceError 就让整页什么都不渲染。
|
||
func TestRenderAll_GuardsEveryStep(t *testing.T) {
|
||
js := dashboardJSSource(t)
|
||
start := strings.Index(js, "async function renderAll(")
|
||
if start == -1 {
|
||
t.Fatal("找不到 renderAll")
|
||
}
|
||
// 取到下一个顶层 "\n}\n" 为止。
|
||
end := strings.Index(js[start:], "\n}\n")
|
||
if end == -1 {
|
||
t.Fatal("renderAll 提取失败")
|
||
}
|
||
body := js[start : start+end]
|
||
|
||
// Promise.all 那一步必须有容错。
|
||
//
|
||
// ★ 两种等价写法都算合格,不要把判据钉死成某一种:
|
||
//
|
||
// ① 逐项 catch:needs.map(n => starmapFetchBlock(n).catch(...))
|
||
// ② 整体 try/catch:try { await Promise.all(...) } catch {}
|
||
//
|
||
// 先前这条判据只认 ②(找字面 "try {"),而修法用的是 ① ⇒
|
||
// 修好了判据却变红。**判据必须钉「语义」不钉「写法」**,
|
||
// 否则它会惩罚正确的实现。
|
||
pa := strings.Index(body, "Promise.all")
|
||
if pa == -1 {
|
||
t.Fatal("renderAll 里没有 Promise.all,结构变了?")
|
||
}
|
||
before := body[max(0, pa-400):pa]
|
||
after := body[pa:min(len(body), pa+900)]
|
||
|
||
// ① 逐项 catch:在 map 回调里对每个 promise 接 .catch(。
|
||
// 窗口要够大:真实写法里 .catch 距 Promise.all 约 200+ 字符
|
||
// (中间隔着 needs.map(function (n) { … })),窗口太小会漏判。
|
||
hasPerItemCatch := strings.Contains(after, ").catch(") ||
|
||
strings.Contains(after, ").catch(function") ||
|
||
strings.Contains(after, ".catch((e)") ||
|
||
strings.Contains(after, ".catch(function")
|
||
// ② 整体 try/catch:Promise.all 前后有 try { … } catch。
|
||
hasTryCatch := (strings.Contains(before, "try {") || strings.Contains(before, "try{")) &&
|
||
strings.Contains(after, "catch")
|
||
|
||
if !hasPerItemCatch && !hasTryCatch {
|
||
t.Errorf("renderAll 的 Promise.all(fetch 步骤)没有容错 ——" +
|
||
"任一 fetch 块抛错会让 Promise.all 整体 reject,**后面的 spec.render 根本不执行**" +
|
||
" ⇒ 整页白屏(生产实测:starmapFetchBlock(\"kernel\") 里的 ReferenceError" +
|
||
" 连带 overview/plugins/kernel 三页全白)。\n" +
|
||
"★ 合格写法二选一:逐项 .catch(...),或整体 try/catch。" +
|
||
"参考同函数里 spec.render 已有的 typeof 守卫 —— 那一步一直有兜底," +
|
||
"唯独跑在它之前的 fetch 步骤没有。")
|
||
}
|
||
}
|
||
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func max(a, b int) int {
|
||
if a > b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// TestDashboardJS_NoWriteOnlyGlobals 钉住「只写不读的全局变量」这类静默失效。
|
||
//
|
||
// ★ 为什么需要(2026-10-08 实际踩到):`starmapDirty = true` 在 dashboard.js
|
||
// 里出现两处,但**没有任何地方读它**,而且它连声明都没有(赋的是隐式全局)。
|
||
// 后果是**新写入的记忆块永远不会出现在星图上** —— 后端的 memory_access
|
||
// 事件一路发到这里、标志位也置了,就是没人消费,整条链路静默断在这里。
|
||
//
|
||
// 这类 bug 的特征:不报错、不抛异常、UI 上看不出异常(图只是“不长”),
|
||
// 既有的作用域判据(真跑 node)也抓不到 —— 因为隐式全局赋值在非严格模式下
|
||
// 完全合法。所以必须用静态判据单独钉。
|
||
func TestDashboardJS_NoWriteOnlyGlobals(t *testing.T) {
|
||
raw := dashboardJSSource(t)
|
||
// ★ 必须先剥掉注释再做词频统计:注释里经常提到变量名(本项目尤其如此,
|
||
// 注释大量引用 `starmapDirty = true` 这类代码片段),不剥会让「出现次数」
|
||
// 虚高,把真正只写不读的变量算成有读。
|
||
js := stripJSComments(raw)
|
||
|
||
// 顶层 `var X` 声明过的名字。
|
||
declRe := regexp.MustCompile(`\bvar\s+([A-Za-z_$][\w$]*)`)
|
||
declared := map[string]bool{}
|
||
for _, m := range declRe.FindAllStringSubmatch(js, -1) {
|
||
declared[m[1]] = true
|
||
}
|
||
|
||
// 只关心布尔标志:赋 true/false 的全局。这类最典型的失效形态就是
|
||
// 「置了标志但没人消费」——功能看着接好了,实际没接。
|
||
for name := range declared {
|
||
nameRe := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`)
|
||
occur := len(nameRe.FindAllString(js, -1))
|
||
writeRe := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*(true|false)\b`)
|
||
writes := len(writeRe.FindAllString(js, -1))
|
||
if writes == 0 {
|
||
continue // 不是布尔标志,不在本判据范围
|
||
}
|
||
// 每次出现要么是一次写入、要么是一次读取。
|
||
// ⇒ 读取次数 = 出现次数 - 写入次数。等于 0 即「只写不读」。
|
||
reads := occur - writes
|
||
if reads <= 0 {
|
||
t.Errorf("全局布尔标志 %s 只被写入、从未被读取(出现 %d 次,写入 %d 次)。\n"+
|
||
" 这类标志位属于**静默失效**:置了标志但无人消费,功能看似接好了实际没接。\n"+
|
||
" 修法:补上消费点,或删掉这个字段。", name, occur, writes)
|
||
}
|
||
}
|
||
}
|
||
|
||
// stripJSComments 剥掉 // 行注释与 /* */ 块注释(粗粒度,但足够用于词频统计)。
|
||
//
|
||
// 不做完整的 JS 词法分析:本判据只用于「统计某个标识符出现次数」,
|
||
// 粗粒度剥离已经足够,且不会误伤字符串里的 //(本文件没有这类内容,
|
||
// 若将来有,宁可少剥也不能多剥 —— 多剥会让判据漏报)。
|
||
func stripJSComments(s string) string {
|
||
var b strings.Builder
|
||
b.Grow(len(s))
|
||
inLine, inBlock := false, false
|
||
var inStr byte
|
||
for i := 0; i < len(s); i++ {
|
||
c := s[i]
|
||
if inLine {
|
||
if c == '\n' {
|
||
inLine = false
|
||
b.WriteByte(c)
|
||
}
|
||
continue
|
||
}
|
||
if inBlock {
|
||
if c == '*' && i+1 < len(s) && s[i+1] == '/' {
|
||
inBlock = false
|
||
i++
|
||
}
|
||
continue
|
||
}
|
||
if c == '\n' {
|
||
b.WriteByte(c)
|
||
continue
|
||
}
|
||
if inStr != 0 {
|
||
b.WriteByte(c)
|
||
if c == '\\' {
|
||
if i+1 < len(s) {
|
||
b.WriteByte(s[i+1])
|
||
i++
|
||
}
|
||
continue
|
||
}
|
||
if c == inStr {
|
||
inStr = 0
|
||
}
|
||
continue
|
||
}
|
||
if c == '"' || c == '\'' || c == '`' {
|
||
// 反引号也是字符串定界符,但它可跨行 —— 本判据只统计标识符,
|
||
// 跨行模板串里出现标识符属正常,此处按普通字符串处理即可。
|
||
inStr = c
|
||
b.WriteByte(c)
|
||
continue
|
||
}
|
||
if c == '/' && i+1 < len(s) && s[i+1] == '/' {
|
||
inLine = true
|
||
i++
|
||
continue
|
||
}
|
||
if c == '/' && i+1 < len(s) && s[i+1] == '*' {
|
||
inBlock = true
|
||
i++
|
||
continue
|
||
}
|
||
b.WriteByte(c)
|
||
}
|
||
return b.String()
|
||
}
|