@ats-cx/cx-core 0.1.0
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.
- package/README.md +23 -0
- package/dist/apm/client.d.ts +59 -0
- package/dist/apm/client.js +247 -0
- package/dist/apm/config-state.d.ts +19 -0
- package/dist/apm/config-state.js +18 -0
- package/dist/apm/config.d.ts +93 -0
- package/dist/apm/config.js +72 -0
- package/dist/apm/errors.d.ts +39 -0
- package/dist/apm/errors.js +39 -0
- package/dist/apm/portal.d.ts +20 -0
- package/dist/apm/portal.js +89 -0
- package/dist/apm/values.d.ts +13 -0
- package/dist/apm/values.js +58 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +168 -0
- package/dist/db/client.d.ts +23 -0
- package/dist/db/client.js +21 -0
- package/dist/diff/project-diff.d.ts +125 -0
- package/dist/diff/project-diff.js +531 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +23 -0
- package/dist/normalize/log.d.ts +12 -0
- package/dist/normalize/log.js +143 -0
- package/dist/normalize/project.d.ts +18 -0
- package/dist/normalize/project.js +254 -0
- package/dist/providers/log-provider.d.ts +27 -0
- package/dist/providers/log-provider.js +63 -0
- package/dist/providers/project-provider.d.ts +10 -0
- package/dist/providers/project-provider.js +22 -0
- package/dist/providers/remote-project-provider.d.ts +28 -0
- package/dist/providers/remote-project-provider.js +167 -0
- package/dist/providers/source-provider.d.ts +11 -0
- package/dist/providers/source-provider.js +33 -0
- package/dist/rules/engine.d.ts +13 -0
- package/dist/rules/engine.js +213 -0
- package/dist/run/result-schema.d.ts +98 -0
- package/dist/run/result-schema.js +50 -0
- package/dist/run/store.d.ts +23 -0
- package/dist/run/store.js +49 -0
- package/dist/semantics/resolver.d.ts +37 -0
- package/dist/semantics/resolver.js +220 -0
- package/dist/semantics/table-loader.d.ts +11 -0
- package/dist/semantics/table-loader.js +44 -0
- package/dist/semantics/table-schema.d.ts +30 -0
- package/dist/semantics/table-schema.js +186 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +2 -0
- package/package.json +33 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { EventSemantics } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* 事件语义表。
|
|
4
|
+
*
|
|
5
|
+
* 这层的目标不是“为每个埋点写诊断规则”,而是为事件补充统一语义标签。
|
|
6
|
+
* 规则引擎只消费这些语义标签,不直接依赖具体事件名。
|
|
7
|
+
*
|
|
8
|
+
* 显式语义表由调用方注入(来自 ResolvedConfig.eventSemantics),本模块不读文件。
|
|
9
|
+
*/
|
|
10
|
+
export type SemanticsResolver = (event: {
|
|
11
|
+
eventName: string;
|
|
12
|
+
payload?: Record<string, unknown>;
|
|
13
|
+
} | string) => EventSemantics;
|
|
14
|
+
/**
|
|
15
|
+
* 创建事件语义解析器,显式语义表由调用方注入。
|
|
16
|
+
*/
|
|
17
|
+
export declare function createSemanticsResolver(explicitMap: Record<string, Partial<EventSemantics>>): SemanticsResolver;
|
|
18
|
+
/**
|
|
19
|
+
* 是否属于会改变项目状态的事件。
|
|
20
|
+
* - definite: 明确会改状态
|
|
21
|
+
* - possible: 可能会改状态
|
|
22
|
+
* - commit: 属于保存/持久化,不算“编辑动作”,但在链路判断里很重要
|
|
23
|
+
*/
|
|
24
|
+
export declare function hasStateChange(semantics: EventSemantics, options?: {
|
|
25
|
+
includePossible?: boolean;
|
|
26
|
+
includeCommit?: boolean;
|
|
27
|
+
}): boolean;
|
|
28
|
+
/** 判断语义是否表示“保存动作”。 */
|
|
29
|
+
export declare function isSaveEvent(semantics: EventSemantics): boolean;
|
|
30
|
+
/** 判断语义是否表示“进入编辑态/页面”。 */
|
|
31
|
+
export declare function isEnterEvent(semantics: EventSemantics): boolean;
|
|
32
|
+
/** 判断语义是否表示“离开编辑态/页面”。 */
|
|
33
|
+
export declare function isLeaveEvent(semantics: EventSemantics): boolean;
|
|
34
|
+
/** 判断语义是否属于显式错误类事件。 */
|
|
35
|
+
export declare function isErrorEvent(semantics: EventSemantics): boolean;
|
|
36
|
+
/** 把状态变更级别转成人类可读中文,供诊断文案复用。 */
|
|
37
|
+
export declare function describeStateChange(semantics: EventSemantics): string;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 名称推断规则。
|
|
3
|
+
*
|
|
4
|
+
* 这是语义层的“兜底能力”:
|
|
5
|
+
* - 如果事件没有显式配置,就尝试按名字模式推断;
|
|
6
|
+
* - 推断结果优先级低于显式配置;
|
|
7
|
+
* - 推断只提供“足够好”的默认值,不追求 100% 正确。
|
|
8
|
+
*/
|
|
9
|
+
const INFERENCE_RULES = [
|
|
10
|
+
{
|
|
11
|
+
test: /error|fail|exception/i,
|
|
12
|
+
semantics: {
|
|
13
|
+
category: "error",
|
|
14
|
+
action: "error_event",
|
|
15
|
+
entityType: "project",
|
|
16
|
+
stateChange: "none",
|
|
17
|
+
tags: ["explicit_error"],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
test: /save/i,
|
|
22
|
+
semantics: {
|
|
23
|
+
category: "persistence",
|
|
24
|
+
action: "save",
|
|
25
|
+
entityType: "project",
|
|
26
|
+
persistenceStage: "save",
|
|
27
|
+
stateChange: "commit",
|
|
28
|
+
tags: ["project_persist"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
test: /close|leave|exit/i,
|
|
33
|
+
semantics: {
|
|
34
|
+
category: "lifecycle",
|
|
35
|
+
action: "leave",
|
|
36
|
+
entityType: "session",
|
|
37
|
+
lifecycleStage: "leave",
|
|
38
|
+
stateChange: "none",
|
|
39
|
+
tags: ["lifecycle_leave"],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
test: /open|enter/i,
|
|
44
|
+
semantics: {
|
|
45
|
+
category: "lifecycle",
|
|
46
|
+
action: "enter",
|
|
47
|
+
entityType: "session",
|
|
48
|
+
lifecycleStage: "enter",
|
|
49
|
+
stateChange: "none",
|
|
50
|
+
tags: ["lifecycle_enter"],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
test: /switch|navigate|jump/i,
|
|
55
|
+
semantics: {
|
|
56
|
+
category: "navigation",
|
|
57
|
+
action: "navigate",
|
|
58
|
+
entityType: "page",
|
|
59
|
+
stateChange: "none",
|
|
60
|
+
tags: ["navigation"],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
test: /transform|update|replace|delete|add|crop|rotate|move|resize/i,
|
|
65
|
+
semantics: {
|
|
66
|
+
category: "mutation",
|
|
67
|
+
action: "mutate",
|
|
68
|
+
entityType: "element",
|
|
69
|
+
stateChange: "possible",
|
|
70
|
+
tags: ["state_change_candidate"],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
const DEFAULT_SEMANTICS = Object.freeze({
|
|
75
|
+
category: "unknown",
|
|
76
|
+
action: "unknown",
|
|
77
|
+
entityType: "unknown",
|
|
78
|
+
lifecycleStage: null,
|
|
79
|
+
persistenceStage: null,
|
|
80
|
+
stateChange: "none",
|
|
81
|
+
tags: [],
|
|
82
|
+
});
|
|
83
|
+
/**
|
|
84
|
+
* 规范化显式语义配置,保证规则引擎拿到的字段总是完整可预期的。
|
|
85
|
+
*/
|
|
86
|
+
function normalizeExplicitSemantics(semantics) {
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
category: semantics.category || "unknown",
|
|
89
|
+
action: semantics.action || "unknown",
|
|
90
|
+
entityType: semantics.entityType || "unknown",
|
|
91
|
+
lifecycleStage: semantics.lifecycleStage || null,
|
|
92
|
+
persistenceStage: semantics.persistenceStage || null,
|
|
93
|
+
stateChange: semantics.stateChange || "none",
|
|
94
|
+
tags: uniqueArray(Array.isArray(semantics.tags) ? semantics.tags : []),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 基于事件名和 payload 做兜底语义推断。
|
|
99
|
+
*
|
|
100
|
+
* 推断只在没有显式配置时发挥主要作用,因此宁可保守,也不追求过度“聪明”。
|
|
101
|
+
*/
|
|
102
|
+
function inferEventSemantics(eventName, payload = {}) {
|
|
103
|
+
for (const rule of INFERENCE_RULES) {
|
|
104
|
+
if (rule.test.test(eventName)) {
|
|
105
|
+
return { ...rule.semantics };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (payload.elementId || payload.id) {
|
|
109
|
+
return {
|
|
110
|
+
category: "workflow",
|
|
111
|
+
action: "element_related",
|
|
112
|
+
entityType: "element",
|
|
113
|
+
stateChange: "none",
|
|
114
|
+
tags: ["element_context"],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (typeof payload.sheetIndex === "number") {
|
|
118
|
+
return {
|
|
119
|
+
category: "navigation",
|
|
120
|
+
action: "page_related",
|
|
121
|
+
entityType: "page",
|
|
122
|
+
stateChange: "none",
|
|
123
|
+
tags: ["page_context"],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { ...DEFAULT_SEMANTICS };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 对数组做去空值去重,常用于 tags 合并。
|
|
130
|
+
*/
|
|
131
|
+
function uniqueArray(items) {
|
|
132
|
+
return [...new Set(items.filter(Boolean))];
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 创建事件语义解析器,显式语义表由调用方注入。
|
|
136
|
+
*/
|
|
137
|
+
export function createSemanticsResolver(explicitMap) {
|
|
138
|
+
const explicitEventSemantics = Object.freeze(Object.fromEntries(Object.entries(explicitMap).map(([eventName, semantics]) => [
|
|
139
|
+
eventName,
|
|
140
|
+
normalizeExplicitSemantics(semantics),
|
|
141
|
+
])));
|
|
142
|
+
return function resolveEventSemantics(event) {
|
|
143
|
+
const eventName = typeof event === "string" ? event : event.eventName || "";
|
|
144
|
+
const payload = typeof event === "string" ? {} : event.payload || {};
|
|
145
|
+
const inferred = inferEventSemantics(eventName, payload);
|
|
146
|
+
const hasExplicitSemantics = Boolean(explicitEventSemantics[eventName]);
|
|
147
|
+
const explicit = explicitEventSemantics[eventName] || {};
|
|
148
|
+
const resolvedTags = hasExplicitSemantics
|
|
149
|
+
? uniqueArray((explicit.tags && explicit.tags.length ? explicit.tags : inferred.tags) || [])
|
|
150
|
+
: uniqueArray(inferred.tags || []);
|
|
151
|
+
const merged = {
|
|
152
|
+
...DEFAULT_SEMANTICS,
|
|
153
|
+
...inferred,
|
|
154
|
+
...explicit,
|
|
155
|
+
tags: resolvedTags,
|
|
156
|
+
source: {
|
|
157
|
+
explicit: hasExplicitSemantics,
|
|
158
|
+
inferred: inferred.category !== "unknown",
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
if (payload.message || payload.stack) {
|
|
162
|
+
merged.tags = uniqueArray([...merged.tags, "error_payload"]);
|
|
163
|
+
if (merged.category === "unknown") {
|
|
164
|
+
merged.category = "error";
|
|
165
|
+
merged.action = "error_payload";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return merged;
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 是否属于会改变项目状态的事件。
|
|
173
|
+
* - definite: 明确会改状态
|
|
174
|
+
* - possible: 可能会改状态
|
|
175
|
+
* - commit: 属于保存/持久化,不算“编辑动作”,但在链路判断里很重要
|
|
176
|
+
*/
|
|
177
|
+
export function hasStateChange(semantics, options = {}) {
|
|
178
|
+
const includePossible = options.includePossible !== false;
|
|
179
|
+
const includeCommit = options.includeCommit === true;
|
|
180
|
+
if (semantics.stateChange === "definite") {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (includePossible && semantics.stateChange === "possible") {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (includeCommit && semantics.stateChange === "commit") {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
/** 判断语义是否表示“保存动作”。 */
|
|
192
|
+
export function isSaveEvent(semantics) {
|
|
193
|
+
return semantics.category === "persistence" && semantics.persistenceStage === "save";
|
|
194
|
+
}
|
|
195
|
+
/** 判断语义是否表示“进入编辑态/页面”。 */
|
|
196
|
+
export function isEnterEvent(semantics) {
|
|
197
|
+
return semantics.category === "lifecycle" && semantics.lifecycleStage === "enter";
|
|
198
|
+
}
|
|
199
|
+
/** 判断语义是否表示“离开编辑态/页面”。 */
|
|
200
|
+
export function isLeaveEvent(semantics) {
|
|
201
|
+
return semantics.category === "lifecycle" && semantics.lifecycleStage === "leave";
|
|
202
|
+
}
|
|
203
|
+
/** 判断语义是否属于显式错误类事件。 */
|
|
204
|
+
export function isErrorEvent(semantics) {
|
|
205
|
+
return semantics.category === "error";
|
|
206
|
+
}
|
|
207
|
+
/** 把状态变更级别转成人类可读中文,供诊断文案复用。 */
|
|
208
|
+
export function describeStateChange(semantics) {
|
|
209
|
+
if (semantics.stateChange === "definite") {
|
|
210
|
+
return "明确修改类事件";
|
|
211
|
+
}
|
|
212
|
+
if (semantics.stateChange === "possible") {
|
|
213
|
+
return "可能修改类事件";
|
|
214
|
+
}
|
|
215
|
+
if (semantics.stateChange === "commit") {
|
|
216
|
+
return "持久化事件";
|
|
217
|
+
}
|
|
218
|
+
return "非修改类事件";
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=resolver.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { EventSemantics } from "../types.js";
|
|
2
|
+
export type EventSemanticsTable = Record<string, Partial<EventSemantics>>;
|
|
3
|
+
/**
|
|
4
|
+
* 读一份事件语义表并跑共享校验——所有读表路径的唯一入口(spec §4 消费方 2)。
|
|
5
|
+
*
|
|
6
|
+
* required=true 用于工作区模式:工作区表是唯一来源,缺表不回落工具箱种子表,
|
|
7
|
+
* 静默回落会让诊断读到别的仓库的语义,比报错难查得多。
|
|
8
|
+
*/
|
|
9
|
+
export declare function loadEventSemanticsTable(filePath: string, options?: {
|
|
10
|
+
required?: boolean;
|
|
11
|
+
}): EventSemanticsTable;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { validateEventSemanticsTable } from "./table-schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* 读一份事件语义表并跑共享校验——所有读表路径的唯一入口(spec §4 消费方 2)。
|
|
5
|
+
*
|
|
6
|
+
* required=true 用于工作区模式:工作区表是唯一来源,缺表不回落工具箱种子表,
|
|
7
|
+
* 静默回落会让诊断读到别的仓库的语义,比报错难查得多。
|
|
8
|
+
*/
|
|
9
|
+
export function loadEventSemanticsTable(filePath, options = {}) {
|
|
10
|
+
if (!existsSync(filePath)) {
|
|
11
|
+
if (options.required) {
|
|
12
|
+
throw new Error(`错误: 事件语义表 ${filePath} 不存在。\n` +
|
|
13
|
+
"请在宿主仓库根重新运行 cx-cli init 补齐(工作区模式不回落工具箱种子表)。");
|
|
14
|
+
}
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = readFileSync(filePath, "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
23
|
+
throw new Error(`错误: 无法读取事件语义表 ${filePath}(${reason})。\n` +
|
|
24
|
+
"请确认路径是可读 JSON 文件/检查路径权限;工作区文件缺失可在宿主根运行 cx-cli init。");
|
|
25
|
+
}
|
|
26
|
+
let table;
|
|
27
|
+
try {
|
|
28
|
+
table = JSON.parse(text);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
32
|
+
throw new Error(`错误: 事件语义表 ${filePath} 不是合法 JSON(${reason})。`);
|
|
33
|
+
}
|
|
34
|
+
const violations = validateEventSemanticsTable({ text, table });
|
|
35
|
+
if (violations.length > 0) {
|
|
36
|
+
throw new Error([
|
|
37
|
+
`错误: 事件语义表 ${filePath} 未通过校验(${violations.length} 项):`,
|
|
38
|
+
...violations.map(violation => ` - ${violation}`),
|
|
39
|
+
"规则、空白白名单与大小写登记清单见 packages/core/src/semantics/table-schema.ts。",
|
|
40
|
+
].join("\n"));
|
|
41
|
+
}
|
|
42
|
+
return table;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=table-loader.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 尾/前导空白键白名单(spec §4.2 规则 1)。
|
|
3
|
+
* 均为宿主真实埋点(02 票源码钉死:埋点管线不 trim、运行时精确匹配),不是维护事故。
|
|
4
|
+
* 宿主修掉笔误后从这里移除——收缩前须确认无其他宿主的工作区表仍持有该键。
|
|
5
|
+
*/
|
|
6
|
+
export declare const WHITESPACE_KEY_ALLOWLIST: ReadonlyArray<{
|
|
7
|
+
key: string;
|
|
8
|
+
evidence: string;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* 大小写重复键登记清单(spec §4.2 规则 2):登记制,不硬禁。
|
|
12
|
+
* 运行时按事件名精确匹配,三组都是两条真实埋点(camelCase 走 logger.track,
|
|
13
|
+
* PascalCase 走老的 logEvent.addPageEvent);未登记的新冲突组一律报错。
|
|
14
|
+
*/
|
|
15
|
+
export declare const CASE_CONFLICT_REGISTRY: ReadonlyArray<ReadonlyArray<string>>;
|
|
16
|
+
/**
|
|
17
|
+
* 校验一份事件语义表,返回违规文案数组(空数组 = 通过)。
|
|
18
|
+
* text 是表的原始 JSON 文本:JSON 文本层的完全重复键只有读原文才看得见
|
|
19
|
+
* (JSON.parse 会静默让后者覆盖前者)。
|
|
20
|
+
*/
|
|
21
|
+
export declare function validateEventSemanticsTable(input: {
|
|
22
|
+
text: string;
|
|
23
|
+
table: unknown;
|
|
24
|
+
}): string[];
|
|
25
|
+
/**
|
|
26
|
+
* 从原始 JSON 文本收集顶层键(含重复)。
|
|
27
|
+
* 逐字符扫描:字符串字面量整段跳过(避免值里的花括号扰动深度),
|
|
28
|
+
* 深度为 1 且其后第一个非空白字符是冒号的字符串,即顶层键。
|
|
29
|
+
*/
|
|
30
|
+
export declare function collectTopLevelKeys(text: string): string[];
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* 事件语义表的共享校验(spec 组件 A)。
|
|
4
|
+
*
|
|
5
|
+
* 纯函数、不碰文件系统:读表在 table-loader.ts,测试与运行时加载共用这里的规则。
|
|
6
|
+
* 下面的 zod 枚举与两份清单即权威口径,不另写值域文档。
|
|
7
|
+
*/
|
|
8
|
+
/** 条目 schema:strict object——未知字段名一律报错,防 provenance 之类的字段名笔误混进来。 */
|
|
9
|
+
const entrySchema = z
|
|
10
|
+
.object({
|
|
11
|
+
category: z.enum(["lifecycle", "mutation", "navigation", "persistence", "workflow", "error"]),
|
|
12
|
+
action: z.string().min(1),
|
|
13
|
+
entityType: z.enum(["project", "session", "page", "element"]),
|
|
14
|
+
lifecycleStage: z.enum(["enter", "leave"]).optional(),
|
|
15
|
+
persistenceStage: z.literal("save").optional(),
|
|
16
|
+
stateChange: z.enum(["none", "possible", "definite", "commit"]),
|
|
17
|
+
tags: z.array(z.string().min(1)).min(1),
|
|
18
|
+
})
|
|
19
|
+
.strict()
|
|
20
|
+
.refine(entry => (entry.category === "lifecycle") === (entry.lifecycleStage !== undefined), {
|
|
21
|
+
message: "category=lifecycle 与 lifecycleStage 必须同时出现(互为充要)",
|
|
22
|
+
})
|
|
23
|
+
.refine(entry => (entry.category === "persistence") === (entry.persistenceStage !== undefined), {
|
|
24
|
+
message: "category=persistence 与 persistenceStage 必须同时出现(互为充要)",
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* 尾/前导空白键白名单(spec §4.2 规则 1)。
|
|
28
|
+
* 均为宿主真实埋点(02 票源码钉死:埋点管线不 trim、运行时精确匹配),不是维护事故。
|
|
29
|
+
* 宿主修掉笔误后从这里移除——收缩前须确认无其他宿主的工作区表仍持有该键。
|
|
30
|
+
*/
|
|
31
|
+
export const WHITESPACE_KEY_ALLOWLIST = [
|
|
32
|
+
{ key: "addVarnishingText ", evidence: "src:src/apps/photobook/components/OptionalButtonList/index.js#L331" },
|
|
33
|
+
{ key: "addSpineVarnishingText ", evidence: "src:src/apps/photobook/components/OptionalButtonList/index.js#L314" },
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* 大小写重复键登记清单(spec §4.2 规则 2):登记制,不硬禁。
|
|
37
|
+
* 运行时按事件名精确匹配,三组都是两条真实埋点(camelCase 走 logger.track,
|
|
38
|
+
* PascalCase 走老的 logEvent.addPageEvent);未登记的新冲突组一律报错。
|
|
39
|
+
*/
|
|
40
|
+
export const CASE_CONFLICT_REGISTRY = [
|
|
41
|
+
["saveProject", "SaveProject"],
|
|
42
|
+
["clickCropImage", "ClickCropImage"],
|
|
43
|
+
["addVarnishingText", "addVarnishingText "],
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* 校验一份事件语义表,返回违规文案数组(空数组 = 通过)。
|
|
47
|
+
* text 是表的原始 JSON 文本:JSON 文本层的完全重复键只有读原文才看得见
|
|
48
|
+
* (JSON.parse 会静默让后者覆盖前者)。
|
|
49
|
+
*/
|
|
50
|
+
export function validateEventSemanticsTable(input) {
|
|
51
|
+
const { text, table } = input;
|
|
52
|
+
if (typeof table !== "object" || table === null || Array.isArray(table)) {
|
|
53
|
+
return ["表本身不是 JSON 对象(应为 事件名 → 语义条目 的映射)"];
|
|
54
|
+
}
|
|
55
|
+
const entries = table;
|
|
56
|
+
const violations = [];
|
|
57
|
+
const conflictViolations = checkCaseConflicts(Object.keys(entries));
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const key of collectTopLevelKeys(text)) {
|
|
60
|
+
if (seen.has(key)) {
|
|
61
|
+
violations.push(`键 ${JSON.stringify(key)}: JSON 文本层重复出现,JSON.parse 会静默丢弃前一条`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
seen.add(key);
|
|
65
|
+
if (!Object.prototype.hasOwnProperty.call(entries, key)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
appendEntryViolations(violations, key, entries[key]);
|
|
69
|
+
violations.push(...(conflictViolations.get(key) ?? []));
|
|
70
|
+
}
|
|
71
|
+
// 若 text 与 table 不一致,仍校验 table 中未出现在原文的键。
|
|
72
|
+
for (const key of Object.keys(entries)) {
|
|
73
|
+
if (seen.has(key)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
appendEntryViolations(violations, key, entries[key]);
|
|
77
|
+
violations.push(...(conflictViolations.get(key) ?? []));
|
|
78
|
+
}
|
|
79
|
+
return violations;
|
|
80
|
+
}
|
|
81
|
+
function appendEntryViolations(violations, key, value) {
|
|
82
|
+
violations.push(...checkKey(key));
|
|
83
|
+
const parsed = entrySchema.safeParse(value);
|
|
84
|
+
if (parsed.success) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const issue of parsed.error.issues) {
|
|
88
|
+
const path = issue.path.length > 0 ? `.${issue.path.join(".")}` : "";
|
|
89
|
+
violations.push(`键 ${JSON.stringify(key)}${path}: ${issue.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** 单键的空白/控制字符/归一化规则(spec §4.2 规则 1、3)。 */
|
|
93
|
+
function checkKey(key) {
|
|
94
|
+
const violations = [];
|
|
95
|
+
const quoted = JSON.stringify(key);
|
|
96
|
+
if (/[\t\n\r]/.test(key)) {
|
|
97
|
+
violations.push(`键 ${quoted}: 含制表符或换行(无例外硬禁)`);
|
|
98
|
+
}
|
|
99
|
+
if (/ {2,}/.test(key)) {
|
|
100
|
+
violations.push(`键 ${quoted}: 含内部连续空格(无例外硬禁)`);
|
|
101
|
+
}
|
|
102
|
+
if (key.trim() === "") {
|
|
103
|
+
violations.push(`键 ${quoted}: 全空白或空键(无例外硬禁)`);
|
|
104
|
+
}
|
|
105
|
+
else if (key !== key.trim() && !WHITESPACE_KEY_ALLOWLIST.some(item => item.key === key)) {
|
|
106
|
+
violations.push(`键 ${quoted}: 含尾部/前导空白且不在白名单内;确属宿主真实埋点请附 src: 证据登记进 WHITESPACE_KEY_ALLOWLIST`);
|
|
107
|
+
}
|
|
108
|
+
if (key !== key.normalize("NFC")) {
|
|
109
|
+
violations.push(`键 ${quoted}: 不是 Unicode NFC 归一化形式(无例外硬禁)`);
|
|
110
|
+
}
|
|
111
|
+
return violations;
|
|
112
|
+
}
|
|
113
|
+
/** trim+casefold 冲突组必须在登记清单里逐组登记(spec §4.2 规则 2)。 */
|
|
114
|
+
function checkCaseConflicts(keys) {
|
|
115
|
+
const groups = new Map();
|
|
116
|
+
for (const key of keys) {
|
|
117
|
+
const folded = key.trim().toLowerCase();
|
|
118
|
+
groups.set(folded, [...(groups.get(folded) ?? []), key]);
|
|
119
|
+
}
|
|
120
|
+
const registered = new Set(CASE_CONFLICT_REGISTRY.map(groupId));
|
|
121
|
+
const violations = new Map();
|
|
122
|
+
for (const [folded, group] of groups) {
|
|
123
|
+
if (group.length < 2 || registered.has(groupId(group))) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const message = `大小写重复键组 [${group.map(key => JSON.stringify(key)).join(", ")}](trim+casefold 后同为 ${JSON.stringify(folded)})未登记;` +
|
|
127
|
+
"确属两条真实埋点请登记进 CASE_CONFLICT_REGISTRY,否则删掉多余的一条";
|
|
128
|
+
const firstKey = group[0];
|
|
129
|
+
violations.set(firstKey, [...(violations.get(firstKey) ?? []), message]);
|
|
130
|
+
}
|
|
131
|
+
return violations;
|
|
132
|
+
}
|
|
133
|
+
/** 组标识与成员顺序无关。 */
|
|
134
|
+
function groupId(group) {
|
|
135
|
+
return JSON.stringify([...group].sort());
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 从原始 JSON 文本收集顶层键(含重复)。
|
|
139
|
+
* 逐字符扫描:字符串字面量整段跳过(避免值里的花括号扰动深度),
|
|
140
|
+
* 深度为 1 且其后第一个非空白字符是冒号的字符串,即顶层键。
|
|
141
|
+
*/
|
|
142
|
+
export function collectTopLevelKeys(text) {
|
|
143
|
+
const keys = [];
|
|
144
|
+
let depth = 0;
|
|
145
|
+
let index = 0;
|
|
146
|
+
while (index < text.length) {
|
|
147
|
+
const char = text[index];
|
|
148
|
+
if (char === '"') {
|
|
149
|
+
const { value, next } = readJsonString(text, index);
|
|
150
|
+
let cursor = next;
|
|
151
|
+
while (cursor < text.length && /\s/.test(text[cursor])) {
|
|
152
|
+
cursor += 1;
|
|
153
|
+
}
|
|
154
|
+
if (depth === 1 && text[cursor] === ":") {
|
|
155
|
+
keys.push(value);
|
|
156
|
+
}
|
|
157
|
+
index = next;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (char === "{" || char === "[") {
|
|
161
|
+
depth += 1;
|
|
162
|
+
}
|
|
163
|
+
else if (char === "}" || char === "]") {
|
|
164
|
+
depth -= 1;
|
|
165
|
+
}
|
|
166
|
+
index += 1;
|
|
167
|
+
}
|
|
168
|
+
return keys;
|
|
169
|
+
}
|
|
170
|
+
/** 读一段 JSON 字符串字面量:转义感知地找到收尾引号,再交给 JSON.parse 解码。 */
|
|
171
|
+
function readJsonString(text, start) {
|
|
172
|
+
let index = start + 1;
|
|
173
|
+
while (index < text.length) {
|
|
174
|
+
const char = text[index];
|
|
175
|
+
if (char === "\\") {
|
|
176
|
+
index += 2;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (char === '"') {
|
|
180
|
+
return { value: JSON.parse(text.slice(start, index + 1)), next: index + 1 };
|
|
181
|
+
}
|
|
182
|
+
index += 1;
|
|
183
|
+
}
|
|
184
|
+
throw new Error("事件语义表 JSON 文本不完整:字符串字面量未闭合");
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=table-schema.js.map
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { ApmProviderConfig } from "./apm/config.js";
|
|
2
|
+
import type { ApmProviderState } from "./apm/config-state.js";
|
|
3
|
+
/** 归一化后的单条日志事件。 */
|
|
4
|
+
export interface NormalizedEvent {
|
|
5
|
+
originalIndex: number;
|
|
6
|
+
eventName: string;
|
|
7
|
+
logTime: string;
|
|
8
|
+
logTimestamp: number;
|
|
9
|
+
/** 服务器接收时间(批次到达时刻);日志源无该字段时为空串,不回退 log_time。不参与排序。 */
|
|
10
|
+
receivedTime: string;
|
|
11
|
+
receivedTimestamp: number;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
projectId: string;
|
|
14
|
+
userId: string;
|
|
15
|
+
type: string;
|
|
16
|
+
subType: string;
|
|
17
|
+
pageUrl: string;
|
|
18
|
+
route: string;
|
|
19
|
+
query: Record<string, string>;
|
|
20
|
+
payload: Record<string, unknown>;
|
|
21
|
+
device: {
|
|
22
|
+
osType: string;
|
|
23
|
+
deviceType: string;
|
|
24
|
+
host: string;
|
|
25
|
+
agentVersion: string;
|
|
26
|
+
terminalId: string;
|
|
27
|
+
};
|
|
28
|
+
raw: unknown;
|
|
29
|
+
}
|
|
30
|
+
export interface SessionGroup {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
projectId: string;
|
|
33
|
+
userId: string;
|
|
34
|
+
startedAt: string;
|
|
35
|
+
endedAt: string;
|
|
36
|
+
eventCount: number;
|
|
37
|
+
events: NormalizedEvent[];
|
|
38
|
+
}
|
|
39
|
+
export interface LogEnvelope {
|
|
40
|
+
generatedAt: string;
|
|
41
|
+
sourcePath: string;
|
|
42
|
+
eventCount: number;
|
|
43
|
+
sessions: SessionGroup[];
|
|
44
|
+
events: NormalizedEvent[];
|
|
45
|
+
}
|
|
46
|
+
export type StateChange = "none" | "possible" | "definite" | "commit";
|
|
47
|
+
export interface EventSemantics {
|
|
48
|
+
category: string;
|
|
49
|
+
action: string;
|
|
50
|
+
entityType: string;
|
|
51
|
+
lifecycleStage: "enter" | "leave" | null;
|
|
52
|
+
persistenceStage: "save" | null;
|
|
53
|
+
stateChange: StateChange;
|
|
54
|
+
tags: string[];
|
|
55
|
+
source?: {
|
|
56
|
+
explicit: boolean;
|
|
57
|
+
inferred: boolean;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export type Severity = "high" | "medium" | "low";
|
|
61
|
+
export interface DiagnosticRule {
|
|
62
|
+
id: string;
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
kind: string;
|
|
65
|
+
description: string;
|
|
66
|
+
severity: Severity | null;
|
|
67
|
+
maxGapSeconds: number | null;
|
|
68
|
+
lookahead: number;
|
|
69
|
+
includePossibleStateChange: boolean;
|
|
70
|
+
severityByStateChange: Partial<Record<StateChange, Severity>>;
|
|
71
|
+
reasonTemplates: {
|
|
72
|
+
default?: string;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export interface SuspiciousSignal {
|
|
76
|
+
severity: Severity;
|
|
77
|
+
ruleId: string;
|
|
78
|
+
type: string;
|
|
79
|
+
eventName: string;
|
|
80
|
+
logTime: string;
|
|
81
|
+
reason: string;
|
|
82
|
+
}
|
|
83
|
+
export interface AnnotatedEvent extends NormalizedEvent {
|
|
84
|
+
semantics: EventSemantics;
|
|
85
|
+
}
|
|
86
|
+
export interface ProjectPage {
|
|
87
|
+
id: string | null;
|
|
88
|
+
type: string | null;
|
|
89
|
+
path: string;
|
|
90
|
+
order: number;
|
|
91
|
+
collectionName: string;
|
|
92
|
+
collectionIndex: number;
|
|
93
|
+
width: number | null;
|
|
94
|
+
height: number | null;
|
|
95
|
+
elementCount: number;
|
|
96
|
+
elementTypes: Record<string, number>;
|
|
97
|
+
}
|
|
98
|
+
export interface ProjectElement {
|
|
99
|
+
lookupKey: string;
|
|
100
|
+
id: string | null;
|
|
101
|
+
oriElementId: string | null;
|
|
102
|
+
type: string;
|
|
103
|
+
pageId: string | null;
|
|
104
|
+
pagePath: string;
|
|
105
|
+
pageOrder: number;
|
|
106
|
+
pageCollection: string;
|
|
107
|
+
pageCollectionIndex: number;
|
|
108
|
+
elementOrder: number;
|
|
109
|
+
imageRef: string | null;
|
|
110
|
+
imageName: string | null;
|
|
111
|
+
geometry: Record<string, unknown>;
|
|
112
|
+
crop: Record<string, unknown>;
|
|
113
|
+
meta: Record<string, unknown>;
|
|
114
|
+
}
|
|
115
|
+
export interface NormalizedProject {
|
|
116
|
+
generatedAt: string;
|
|
117
|
+
sourcePath: string;
|
|
118
|
+
projectMeta: Record<string, unknown>;
|
|
119
|
+
imageCount: number;
|
|
120
|
+
pageCount: number;
|
|
121
|
+
elementCount: number;
|
|
122
|
+
pageCollectionCounts: Record<string, number>;
|
|
123
|
+
elementTypeCounts: Record<string, number>;
|
|
124
|
+
pages: ProjectPage[];
|
|
125
|
+
elements: ProjectElement[];
|
|
126
|
+
elementIndex: Record<string, ProjectElement>;
|
|
127
|
+
images: Array<{
|
|
128
|
+
guid: string | null;
|
|
129
|
+
encImgId: string | null;
|
|
130
|
+
name: string | null;
|
|
131
|
+
width: number | null;
|
|
132
|
+
height: number | null;
|
|
133
|
+
uploadTime: string | null;
|
|
134
|
+
}>;
|
|
135
|
+
}
|
|
136
|
+
/** 运行时源码仓库绑定:id + 绝对路径。三仓由 CLI 的 HOST_SOURCE_BINDINGS 合成,不来自配置文件。 */
|
|
137
|
+
export interface SourceRepoBinding {
|
|
138
|
+
id: string;
|
|
139
|
+
path: string;
|
|
140
|
+
}
|
|
141
|
+
export interface ResolvedConfig {
|
|
142
|
+
configDir: string;
|
|
143
|
+
runsDir: string;
|
|
144
|
+
outDir: string;
|
|
145
|
+
projectHistoryDir: string;
|
|
146
|
+
logSource: {
|
|
147
|
+
type: "apm";
|
|
148
|
+
} | {
|
|
149
|
+
type: "file";
|
|
150
|
+
path: string;
|
|
151
|
+
};
|
|
152
|
+
apmProvider: ApmProviderConfig | null;
|
|
153
|
+
/** null 表示已配置;其余三态说明不可用原因与应填写的覆盖文件。 */
|
|
154
|
+
apmProviderState: ApmProviderState | null;
|
|
155
|
+
eventSemantics: Record<string, Partial<EventSemantics>>;
|
|
156
|
+
diagnosticRules: DiagnosticRule[];
|
|
157
|
+
}
|
|
158
|
+
export interface SourceVersion {
|
|
159
|
+
repoId: string;
|
|
160
|
+
commit: string | null;
|
|
161
|
+
branch: string | null;
|
|
162
|
+
dirty: boolean | null;
|
|
163
|
+
reachable: boolean;
|
|
164
|
+
}
|