diff --git a/docs/superpowers/plans/2026-09-18-collection-foundation-phase-a.md b/docs/superpowers/plans/2026-09-18-collection-foundation-phase-a.md index f444475..ac548b9 100644 --- a/docs/superpowers/plans/2026-09-18-collection-foundation-phase-a.md +++ b/docs/superpowers/plans/2026-09-18-collection-foundation-phase-a.md @@ -41,6 +41,7 @@ {"in": "/lp?gclid=EAIaIQ&fbclid=IwAR2x", "out": "/lp?gclid=EAIaIQ&fbclid=IwAR2x"}, {"in": "/p?token=abcdef0123456789abcdef0123456789x", "out": "/p"}, {"in": "/p?ref=abcdefghijklmnopqrstuvwxyz012345", "out": "/p"}, + {"in": "/p?slug=abcdefghijklmnopqrstuvwxyz0123456", "out": "/p"}, {"in": "/p?cat=sedans&sort=price", "out": "/p?cat=sedans&sort=price"}, {"in": "/p?q=%E5%BC%A0%E4%B8%89%E7%9A%84%E9%82%AE%E7%AE%B1a%40b.com", "out": "/p"}, {"in": "/a/b?name=zhangsan", "out": "/a/b"}, @@ -51,7 +52,7 @@ } ``` -(执行者注意:vectors 覆盖四类——业务参数保留、黑名单参数删、值判别删(@/长随机/超长)、userinfo+fragment 清理。`x=1` 值"1"是短数字→保留。) +(执行者注意:vectors 覆盖四类——业务参数保留、黑名单参数删、值判别删(@/长随机/超长)、userinfo+fragment 清理。`x=1` 值"1"是短数字→保留。**slug 安全线定 32**:`ref=abcdefghijklmnopqrstuvwxyz012345`(32 连续字母数字)按随机 token 删——真实业务 slug 含 `._-` 分隔或更短,32+ 连续字母数字视为 token(D08 威胁模型),33-64 band 由新增的 `slug=` 向量钉死语义。) - [ ] **Step 2: 写失败测试(向量驱动)** @@ -59,6 +60,7 @@ ```python import json, pathlib +from tracker import constants as C VECTORS = json.loads((pathlib.Path(__file__).parent / "fixtures" / "sanitize_vectors.json").read_text(encoding="utf-8")) def test_sanitize_url_vectors(): @@ -84,7 +86,7 @@ URL_PARAM_DROP_PATTERNS = ("email", "phone", "mobile", "tel", "name", "user", "token", "key", "password", "verif", "passwd", "idcard") ATTRIBUTION_PARAM_WHITELIST = {"gclid", "gbraid", "wbraid", "fbclid", "msclkid", "yclid", "gad_source", "gad_campaignid", "dclid"} -_SAFE_VALUE = re.compile(r"^(?:\d{1,10}|[A-Za-z0-9][A-Za-z0-9._-]{0,63})$") +_SAFE_VALUE = re.compile(r"^(?:\d{1,10}|[A-Za-z0-9][A-Za-z0-9._-]{0,30})$") # slug≤32:32+连续字母数字视为token删 ``` (constants.py 顶部需 `import re`。) @@ -322,8 +324,8 @@ git commit -m "test(server): v1/v2混合停留聚合回归+聚合点核查适配 **Files:** - Modify: `sdk/package.json`(version 0.2.0) - Modify: `sdk/build.mjs`(define 注入) -- Modify: `sdk/src/config.ts`(类型) -- Create: `sdk/src/types.ts`(归属快照类型) +- Modify: `sdk/vitest.config.ts`(**define 注入测试值**——否则 `__BUILD_ID__` 在 dispatch 的静默 catch 里 ReferenceError,init 半途而废且零报错) +- Create: `sdk/src/types.ts`(归属快照类型 + `__BUILD_ID__` ambient 声明) - Test: `sdk/test/index.test.ts` 适配 - [ ] **Step 1: types.ts(新建)** @@ -343,7 +345,15 @@ export interface Attribution { export const SDK_VERSION = "0.2.0"; // 与 package.json 同步手维护 export const SCHEMA_VERSION = 2; export const CONFIG_VERSION = "1"; -export declare const __BUILD_ID__: string; // esbuild define 注入 + +/** build_id 由 esbuild/vitest define 注入的全局常量(ambient 声明, + 不能用模块级 export declare——esbuild 打包时会被擦除导致引用悬空) */ +declare global { + // eslint-disable-next-line no-var + var __BUILD_ID__: string | undefined; +} +/** 运行时兜底读取(build 注入缺失时降级 "unknown",绝不抛) */ +export const BUILD_ID: string = globalThis.__BUILD_ID__ ?? "unknown"; ``` - [ ] **Step 2: build.mjs 注入** @@ -358,6 +368,15 @@ await build({ entryPoints: ["src/index.ts"], bundle: true, minify: true, format: (fallback:git 失败时用 `"dev-" + Date.now()`,try/catch 包裹——构建环境无 git 不得挂。) +vitest.config.ts 同步注入(否则 index.ts 引用 BUILD_ID 在测试环境 undefined——有 ?? 兜底不炸,但注入后可断言真实值): + +```ts +export default defineConfig({ + define: { __BUILD_ID__: JSON.stringify("vitest") }, + test: { environment: "happy-dom" }, +}); +``` + - [ ] **Step 3: 失败测试 → 实现 sdk_loaded 携带版本组 → 全量跑过 → Commit** ```bash @@ -478,18 +497,19 @@ test("v1 兼容形态不再产生(新 SDK 恒 schema_version:2 信封 + 事件 **Files:** - Modify: `sdk/src/context.ts:1-58`(URL_PARAM_DROP 表 + sanitizeUrl 重写) +- Modify: `sdk/tsconfig.json`(`resolveJsonModule: true`——跨树 JSON import 的 typecheck 支持) - Test: `sdk/test/context.test.ts`(追加向量测试) - [ ] **Step 1: 失败测试(读 server 的同一份 fixtures)** ```ts import vectors from "../../server/tracker/tests/fixtures/sanitize_vectors.json"; -test.each(vectors.vectors)("sanitizeUrl($in) → $out", ({in, out}) => { - expect(sanitizeUrl(in)).toBe(out); +test.each(vectors.vectors)("sanitizeUrl($input) → $expected", (v) => { + expect(sanitizeUrl(v.in)).toBe(v.out); }); ``` -(vitest JSON import 天然支持;确认 vitest.config.ts 的 include 覆盖 test/,相对路径跨出 sdk/ 无碍。) +(`in` 是保留字不能作解构绑定名——用整对象参数 `v`。vitest/vite 原生支持 JSON import 且无 root 限制(已核 vitest.config.ts),跨出 sdk/ 的相对路径可解析。) - [ ] **Step 2: TS 实现与服务端规则逐条对齐**(黑名单 fullmatch、归因白名单+utm_ 前缀、`_SAFE_VALUE` 同正则、userinfo/fragment 清理——TS 用 `new URL()` 处理 userinfo,注意相对路径 input 时 URL 不可用 → 沿用字符串切分法与 Python urlparse 行为对齐) @@ -528,8 +548,9 @@ test.each(vectors.vectors)("sanitizeUrl($in) → $out", ({in, out}) => { ## 风险提示(执行者必读) -1. **勿动归属之外的既有不变量**:ingest.py 头注释的五条并发不变量(uq_site_event/F 表达式/Greatest ended_at/族缓存)必须保留——v2 改造叠加在其上,不得回归。 -2. **stay_ms 重算是唯一聚合改动**:duration_ms/event_count/ended_at 维持现有 F 增量;只有 stay_ms 从增量改重算。v1+v2 混合解释:v1 SUM + v2 并集相加。 -3. **SDK 红线**:任何回调异常静默(spec §4.5);PageSession/SPA hook 全部 try/catch 包裹。 +1. **勿动归属之外的既有不变量**:ingest.py 头注释五条并发不变量中 ①②④⑤ 全保留,③ 的 F 表达式模式保留——唯 stay_ms 字段按风险项 2 改重算(其余字段 duration_ms/event_count/ended_at 仍 F 增量)。 +2. **stay_ms 重算是唯一聚合改动**:duration_ms/event_count/ended_at 维持现有 F 增量;只有 stay_ms 从增量改重算。v1+v2 混合解释:v1 SUM + v2 并集相加。recompute 时对 `page_id` 非空但 `active_from_ms/active_to_ms` 为 null 的脏数据 filter 防御(validate_event v2 分支强制区间完整,双保险)。 +3. **SDK 红线**:任何回调异常静默(spec §4.5);PageSession/SPA hook 全部 try/catch 包裹。BUILD_ID 经 `globalThis.__BUILD_ID__ ?? "unknown"` 兜底——define 缺失(vitest/异常构建环境)不得炸。 4. **测试向量是契约**:T1 的 fixtures 文件 T9 复用,两侧任何一侧改规则必须同步改向量并两侧重跑。 5. 生产部署顺序:server 先(T11)验证 v1 无回归后才发 SDK(T12);SDK 回滚只需还原 sdk/dist,服务端不动。 +6. **测试 helper 均为新建**:`_base_event_kwargs`/`_now_iso`/`SITE_ID` 等在 server tests 中不存在——执行者创建它们而非寻找。Task 1 Step 6 点名更新既有测试 `test_pageview_referrer_truncated_after_reencode`(CJK `q` 值不再匹配 _SAFE_VALUE,改用超长 utm 值驱动同一路径)。