@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.
Files changed (48) hide show
  1. package/README.md +23 -0
  2. package/dist/apm/client.d.ts +59 -0
  3. package/dist/apm/client.js +247 -0
  4. package/dist/apm/config-state.d.ts +19 -0
  5. package/dist/apm/config-state.js +18 -0
  6. package/dist/apm/config.d.ts +93 -0
  7. package/dist/apm/config.js +72 -0
  8. package/dist/apm/errors.d.ts +39 -0
  9. package/dist/apm/errors.js +39 -0
  10. package/dist/apm/portal.d.ts +20 -0
  11. package/dist/apm/portal.js +89 -0
  12. package/dist/apm/values.d.ts +13 -0
  13. package/dist/apm/values.js +58 -0
  14. package/dist/config.d.ts +22 -0
  15. package/dist/config.js +168 -0
  16. package/dist/db/client.d.ts +23 -0
  17. package/dist/db/client.js +21 -0
  18. package/dist/diff/project-diff.d.ts +125 -0
  19. package/dist/diff/project-diff.js +531 -0
  20. package/dist/index.d.ts +38 -0
  21. package/dist/index.js +23 -0
  22. package/dist/normalize/log.d.ts +12 -0
  23. package/dist/normalize/log.js +143 -0
  24. package/dist/normalize/project.d.ts +18 -0
  25. package/dist/normalize/project.js +254 -0
  26. package/dist/providers/log-provider.d.ts +27 -0
  27. package/dist/providers/log-provider.js +63 -0
  28. package/dist/providers/project-provider.d.ts +10 -0
  29. package/dist/providers/project-provider.js +22 -0
  30. package/dist/providers/remote-project-provider.d.ts +28 -0
  31. package/dist/providers/remote-project-provider.js +167 -0
  32. package/dist/providers/source-provider.d.ts +11 -0
  33. package/dist/providers/source-provider.js +33 -0
  34. package/dist/rules/engine.d.ts +13 -0
  35. package/dist/rules/engine.js +213 -0
  36. package/dist/run/result-schema.d.ts +98 -0
  37. package/dist/run/result-schema.js +50 -0
  38. package/dist/run/store.d.ts +23 -0
  39. package/dist/run/store.js +49 -0
  40. package/dist/semantics/resolver.d.ts +37 -0
  41. package/dist/semantics/resolver.js +220 -0
  42. package/dist/semantics/table-loader.d.ts +11 -0
  43. package/dist/semantics/table-loader.js +44 -0
  44. package/dist/semantics/table-schema.d.ts +30 -0
  45. package/dist/semantics/table-schema.js +186 -0
  46. package/dist/types.d.ts +164 -0
  47. package/dist/types.js +2 -0
  48. package/package.json +33 -0
@@ -0,0 +1,89 @@
1
+ import { ApmRequestError } from "./errors.js";
2
+ export async function resolveUserId(config, params, deps = {}) {
3
+ const url = `${config.baseUrl}/api/portal/get-user-id?email=${encodeURIComponent(params.value)}&host=${encodeURIComponent(params.host)}`;
4
+ const { text } = await requestPortal(config, url, deps);
5
+ const parsed = parseJson(text);
6
+ if (!isRecord(parsed)) {
7
+ throw new ApmRequestError("invalid_response", { message: "APM portal 返回了非 JSON 响应" });
8
+ }
9
+ if (parsed.success === false) {
10
+ throw new ApmRequestError("rejected", {
11
+ message: "无此邮箱/手机,或 portal 接口不可用",
12
+ });
13
+ }
14
+ if (parsed.success !== true || typeof parsed.userId !== "string") {
15
+ throw new ApmRequestError("invalid_response", { message: "APM portal 响应缺少有效的 userId" });
16
+ }
17
+ return parsed.userId;
18
+ }
19
+ export async function fetchLiveProject(config, params, deps = {}) {
20
+ const url = `${config.baseUrl}/api/portal/get-project-data?projectId=${encodeURIComponent(params.projectId)}&host=${encodeURIComponent(params.host)}`;
21
+ const { response, text } = await requestPortal(config, url, deps);
22
+ if (!isJsonContentType(response.headers.get("content-type"))) {
23
+ throw invalidProjectResponse();
24
+ }
25
+ const parsed = parseJson(text);
26
+ if (!isRecord(parsed)) {
27
+ throw invalidProjectResponse();
28
+ }
29
+ const project = isRecord(parsed.project) ? parsed.project : undefined;
30
+ return {
31
+ text,
32
+ parsed,
33
+ updatedDate: project?.updatedDate ?? null,
34
+ hasProject: "project" in parsed,
35
+ };
36
+ }
37
+ async function requestPortal(config, url, deps) {
38
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
39
+ try {
40
+ const response = await fetchImpl(url, {
41
+ method: "GET",
42
+ signal: AbortSignal.timeout(config.timeoutMs.portal),
43
+ });
44
+ if (!response.ok) {
45
+ throw new ApmRequestError("http", {
46
+ status: response.status,
47
+ message: `APM portal 请求失败(HTTP ${response.status})`,
48
+ });
49
+ }
50
+ return { response, text: await response.text() };
51
+ }
52
+ catch (error) {
53
+ if (error instanceof ApmRequestError) {
54
+ throw error;
55
+ }
56
+ if (isAbortError(error)) {
57
+ throw new ApmRequestError("timeout", { message: "APM portal 请求超时" });
58
+ }
59
+ throw new ApmRequestError("http", {
60
+ message: `APM portal 请求失败:${error instanceof Error ? error.message : String(error)}`,
61
+ });
62
+ }
63
+ }
64
+ function parseJson(text) {
65
+ try {
66
+ return JSON.parse(text);
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ function isJsonContentType(value) {
73
+ return value !== null && /^application\/(?:[a-z0-9.+-]+\+)?json(?:\s*;|$)/i.test(value);
74
+ }
75
+ function invalidProjectResponse() {
76
+ return new ApmRequestError("invalid_response", {
77
+ message: "现网 project 接口返回无效响应:可能是项目不存在 / host 不对 / portal 登录失败",
78
+ });
79
+ }
80
+ function isRecord(value) {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+ function isAbortError(error) {
84
+ return (typeof error === "object" &&
85
+ error !== null &&
86
+ "name" in error &&
87
+ (error.name === "AbortError" || error.name === "TimeoutError"));
88
+ }
89
+ //# sourceMappingURL=portal.js.map
@@ -0,0 +1,13 @@
1
+ export declare const LOG_TYPES: readonly ["error", "performance", "api", "paint", "user_behav", "network", "server_error", "footprint", "server_api"];
2
+ export type LogType = (typeof LOG_TYPES)[number];
3
+ export declare const ORDERS: readonly ["ASC", "DESC"];
4
+ export type ApmOrder = (typeof ORDERS)[number];
5
+ /** 请求体由客户端固定生成的键,不能经 where 透传覆盖。 */
6
+ export declare const FIXED_REQUEST_KEYS: ReadonlySet<string>;
7
+ /** 服务端把值直拼 SQL;客户端必须拒绝无法安全透传的字符。 */
8
+ export declare function assertSafeValue(name: string, value: string): void;
9
+ export declare function assertColumnName(value: string): void;
10
+ /** APM portal 只接受带子域的完整站点 host。 */
11
+ export declare function isSiteHost(value: string): boolean;
12
+ /** 把 APM 的客户端本地钟输入归一化为接口接受的无时区格式。 */
13
+ export declare function normalizeLogTime(input: string, edge: "start" | "end"): string;
@@ -0,0 +1,58 @@
1
+ export const LOG_TYPES = [
2
+ "error",
3
+ "performance",
4
+ "api",
5
+ "paint",
6
+ "user_behav",
7
+ "network",
8
+ "server_error",
9
+ "footprint",
10
+ "server_api",
11
+ ];
12
+ export const ORDERS = ["ASC", "DESC"];
13
+ /** 请求体由客户端固定生成的键,不能经 where 透传覆盖。 */
14
+ export const FIXED_REQUEST_KEYS = new Set(["type", "page", "pageSize", "order"]);
15
+ /** 服务端把值直拼 SQL;客户端必须拒绝无法安全透传的字符。 */
16
+ export function assertSafeValue(name, value) {
17
+ if (value.includes("'") || value.includes("\\")) {
18
+ throw new Error(`${name} 不能包含单引号或反斜杠`);
19
+ }
20
+ }
21
+ export function assertColumnName(value) {
22
+ if (!/^[a-z0-9_]+$/.test(value)) {
23
+ throw new Error(`列名 ${value || "<空>"} 无效,只允许小写字母、数字和下划线`);
24
+ }
25
+ }
26
+ /** APM portal 只接受带子域的完整站点 host。 */
27
+ export function isSiteHost(value) {
28
+ return /^[^.]+(\.[^.]+){2,}$/.test(value);
29
+ }
30
+ /** 把 APM 的客户端本地钟输入归一化为接口接受的无时区格式。 */
31
+ export function normalizeLogTime(input, edge) {
32
+ const value = input.trim();
33
+ if (/Z$|[+-]\d{2}:\d{2}$/.test(value)) {
34
+ throw new Error("log_time 是客户端本地钟,无时区语义,请不要携带 Z 或时区偏移");
35
+ }
36
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2}))?$/.exec(value);
37
+ if (!match) {
38
+ throw new Error("时间格式无效,支持 YYYY-MM-DD、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DDTHH:mm:ss");
39
+ }
40
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText] = match;
41
+ const year = Number(yearText);
42
+ const month = Number(monthText);
43
+ const day = Number(dayText);
44
+ const hour = Number(hourText ?? (edge === "start" ? "00" : "23"));
45
+ const minute = Number(minuteText ?? (edge === "start" ? "00" : "59"));
46
+ const second = Number(secondText ?? (edge === "start" ? "00" : "59"));
47
+ const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
48
+ if (date.getUTCFullYear() !== year ||
49
+ date.getUTCMonth() !== month - 1 ||
50
+ date.getUTCDate() !== day ||
51
+ date.getUTCHours() !== hour ||
52
+ date.getUTCMinutes() !== minute ||
53
+ date.getUTCSeconds() !== second) {
54
+ throw new Error("时间值无效,请检查日期和时分秒");
55
+ }
56
+ return `${yearText}-${monthText}-${dayText} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:${String(second).padStart(2, "0")}`;
57
+ }
58
+ //# sourceMappingURL=values.js.map
@@ -0,0 +1,22 @@
1
+ import type { ResolvedConfig } from "./types.js";
2
+ /**
3
+ * 从配置目录加载并合并配置。
4
+ *
5
+ * 约定:
6
+ * - `<name>.json` 是声明(提交 git),`<name>.local.json` 是本机绑定(gitignore)
7
+ * - local 深合并覆盖声明
8
+ * - 声明与同目录 local 的相对路径相对 configDir;机器级 local 相对机器级目录,绝不相对 cwd
9
+ */
10
+ export interface LoadConfigOptions {
11
+ /** 配置目录来源;仅 toolkit 来源叠加机器级 local。 */
12
+ source?: ConfigSource;
13
+ /**
14
+ * 语义表覆盖来源(工作区模式传工作区表路径)。
15
+ * 给定时只读这个路径、缺文件即报错——工作区表是唯一来源,不做两层查找(spec 组件 D)。
16
+ */
17
+ eventSemanticsPath?: string;
18
+ }
19
+ export type ConfigSource = "explicit" | "workspace" | "env" | "toolkit";
20
+ /** cx-cli 的机器级配置与无工作区产物目录。 */
21
+ export declare function machineConfigDir(): string;
22
+ export declare function loadConfig(configDir: string, options?: LoadConfigOptions): ResolvedConfig;
package/dist/config.js ADDED
@@ -0,0 +1,168 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { ZodError } from "zod";
5
+ import { parseApmProviderConfig } from "./apm/config.js";
6
+ import { loadEventSemanticsTable } from "./semantics/table-loader.js";
7
+ /** cx-cli 的机器级配置与无工作区产物目录。 */
8
+ export function machineConfigDir() {
9
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cx-cli");
10
+ }
11
+ export function loadConfig(configDir, options = {}) {
12
+ const dir = resolve(configDir);
13
+ const machineDir = machineConfigDir();
14
+ const machineToolkit = options.source === "toolkit"
15
+ ? resolveToolkitPaths(readJsonIfExists(join(machineDir, "toolkit.local.json")), machineDir)
16
+ : {};
17
+ const toolkit = mergeLocal(mergeLocal(readJsonIfExists(join(dir, "toolkit.json")), machineToolkit), readJsonIfExists(join(dir, "toolkit.local.json")));
18
+ const apmProviderPaths = [
19
+ join(dir, "apm-provider.json"),
20
+ ...(options.source === "toolkit" ? [join(machineDir, "apm-provider.local.json")] : []),
21
+ join(dir, "apm-provider.local.json"),
22
+ ];
23
+ const apmProviderLayers = apmProviderPaths.map(readApmJsonIfExists);
24
+ const apmProviderFiles = apmProviderPaths.filter(existsSync);
25
+ const apmProviderRaw = mergeLocal(mergeLocal(apmProviderLayers[0]?.value ?? {}, apmProviderLayers[1]?.value ?? {}), apmProviderLayers[2]?.value ?? {});
26
+ const fillPath = options.source === "toolkit"
27
+ ? join(machineDir, "apm-provider.local.json")
28
+ : join(dir, "apm-provider.local.json");
29
+ let apmProvider = null;
30
+ let apmProviderState;
31
+ const apmJsonError = apmProviderLayers.find(layer => layer.error !== undefined)?.error;
32
+ if (apmJsonError !== undefined) {
33
+ apmProviderState = { kind: "invalid", fillPath, error: apmJsonError };
34
+ }
35
+ else if (!existsSync(join(dir, "apm-provider.json"))) {
36
+ apmProviderState = { kind: "missing", fillPath, configDir: dir };
37
+ }
38
+ else {
39
+ try {
40
+ const parsed = parseApmConfig(apmProviderRaw);
41
+ if (parsed.baseUrl === "" || parsed.defaults.host === "") {
42
+ apmProviderState = { kind: "unconfigured", fillPath };
43
+ }
44
+ else {
45
+ apmProvider = parsed;
46
+ apmProviderState = null;
47
+ }
48
+ }
49
+ catch (error) {
50
+ apmProviderState = {
51
+ kind: "invalid",
52
+ fillPath,
53
+ error: `${error instanceof Error ? error.message : String(error)}(配置文件:${apmProviderFiles.join("、")})`,
54
+ };
55
+ }
56
+ }
57
+ const eventSemantics = loadEventSemanticsTable(options.eventSemanticsPath ?? join(dir, "event-semantics.json"), {
58
+ required: options.eventSemanticsPath !== undefined,
59
+ });
60
+ const rulesRaw = readJsonIfExists(join(dir, "diagnostic-rules.json"));
61
+ const resolvePath = (value) => (isAbsolute(value) ? value : join(dir, value));
62
+ const logSourceRaw = (toolkit.logSource ?? { type: "apm" });
63
+ const logSource = logSourceRaw.type === "file"
64
+ ? { type: "file", path: resolvePath(logSourceRaw.path) }
65
+ : { type: "apm" };
66
+ return {
67
+ configDir: dir,
68
+ runsDir: resolvePath(String(toolkit.runsDir ?? join(machineConfigDir(), "runs"))),
69
+ outDir: resolvePath(String(toolkit.outDir ?? join(machineConfigDir(), "out"))),
70
+ projectHistoryDir: resolvePath(String(toolkit.projectHistoryDir ?? join(machineConfigDir(), "data"))),
71
+ logSource,
72
+ apmProvider,
73
+ apmProviderState,
74
+ eventSemantics,
75
+ diagnosticRules: (rulesRaw.rules ?? []).map(normalizeRule),
76
+ };
77
+ }
78
+ /** 机器级 toolkit local 中的相对路径相对机器级目录,而不是声明目录解析。 */
79
+ function resolveToolkitPaths(raw, baseDir) {
80
+ const result = { ...raw };
81
+ for (const key of ["runsDir", "outDir", "projectHistoryDir"]) {
82
+ const value = result[key];
83
+ if (typeof value === "string" && !isAbsolute(value)) {
84
+ result[key] = join(baseDir, value);
85
+ }
86
+ }
87
+ if (isPlainObject(result.logSource)) {
88
+ const path = result.logSource.path;
89
+ if (typeof path === "string" && !isAbsolute(path)) {
90
+ result.logSource = { ...result.logSource, path: join(baseDir, path) };
91
+ }
92
+ }
93
+ return result;
94
+ }
95
+ function parseApmConfig(raw) {
96
+ try {
97
+ return parseApmProviderConfig(raw);
98
+ }
99
+ catch (error) {
100
+ if (!(error instanceof ZodError)) {
101
+ throw error;
102
+ }
103
+ const details = error.issues.flatMap(issue => {
104
+ if (issue.code === "unrecognized_keys") {
105
+ return issue.keys.map(key => `${[...issue.path, key].join(".")} 不是允许的键`);
106
+ }
107
+ return [`${issue.path.join(".") || "<root>"} ${issue.message}`];
108
+ });
109
+ throw new Error(`apm-provider.json: ${details.join(";")}`);
110
+ }
111
+ }
112
+ /** 把配置里的原始规则对象补齐默认值,归一化为 DiagnosticRule。 */
113
+ function normalizeRule(rule) {
114
+ return {
115
+ id: String(rule.id ?? rule.kind ?? "unknown_rule"),
116
+ enabled: rule.enabled !== false,
117
+ kind: String(rule.kind ?? "unknown"),
118
+ description: String(rule.description ?? ""),
119
+ severity: rule.severity ?? null,
120
+ maxGapSeconds: typeof rule.maxGapSeconds === "number" ? rule.maxGapSeconds : null,
121
+ lookahead: typeof rule.lookahead === "number" ? rule.lookahead : 8,
122
+ includePossibleStateChange: rule.includePossibleStateChange !== false,
123
+ severityByStateChange: rule.severityByStateChange ?? {},
124
+ reasonTemplates: rule.reasonTemplates ?? {},
125
+ };
126
+ }
127
+ function readJsonIfExists(filePath) {
128
+ if (!existsSync(filePath)) {
129
+ return {};
130
+ }
131
+ return JSON.parse(readFileSync(filePath, "utf8"));
132
+ }
133
+ function readApmJsonIfExists(filePath) {
134
+ if (!existsSync(filePath)) {
135
+ return { value: {} };
136
+ }
137
+ try {
138
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
139
+ if (!isPlainObject(parsed)) {
140
+ return { value: {}, error: `${filePath}: 顶层必须是 JSON 对象` };
141
+ }
142
+ return { value: parsed };
143
+ }
144
+ catch (error) {
145
+ return {
146
+ value: {},
147
+ error: `${filePath}: 不是合法 JSON(${error instanceof Error ? error.message : String(error)})`,
148
+ };
149
+ }
150
+ }
151
+ /** local 深合并覆盖声明:对象递归合并,其余类型直接覆盖。 */
152
+ function mergeLocal(base, local) {
153
+ const result = { ...base };
154
+ for (const [key, value] of Object.entries(local)) {
155
+ const existing = result[key];
156
+ if (isPlainObject(existing) && isPlainObject(value)) {
157
+ result[key] = mergeLocal(existing, value);
158
+ }
159
+ else {
160
+ result[key] = value;
161
+ }
162
+ }
163
+ return result;
164
+ }
165
+ function isPlainObject(value) {
166
+ return typeof value === "object" && value !== null && !Array.isArray(value);
167
+ }
168
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,23 @@
1
+ export type DbRow = Record<string, unknown>;
2
+ /** 占位参数:本仓库的查询只按 PROJECT_UID 过滤,标量足够。 */
3
+ export type DbParam = string | number;
4
+ /** 薄接口:provider 只依赖它,单测注入假实现即可覆盖 SQL 文本与参数。 */
5
+ export interface DbClient {
6
+ query(sql: string, params: DbParam[]): Promise<DbRow[]>;
7
+ close(): Promise<void>;
8
+ }
9
+ export interface MysqlClientConfig {
10
+ host: string;
11
+ user: string;
12
+ password: string;
13
+ database: string;
14
+ port: number;
15
+ }
16
+ /**
17
+ * mysql2/promise 单连接实现。
18
+ *
19
+ * 不设 timezone / charset 等自定义项:CREATE_TIME 按本地时区转 Date 后格式化成文件名,
20
+ * 与存量快照文件名连续,避免配置漂移让同一条记录生成不同文件名。
21
+ * mysql2 用动态 import:诊断命令不碰 DB,不该为它付驱动加载成本。
22
+ */
23
+ export declare function createMysqlClient(config: MysqlClientConfig): Promise<DbClient>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * mysql2/promise 单连接实现。
3
+ *
4
+ * 不设 timezone / charset 等自定义项:CREATE_TIME 按本地时区转 Date 后格式化成文件名,
5
+ * 与存量快照文件名连续,避免配置漂移让同一条记录生成不同文件名。
6
+ * mysql2 用动态 import:诊断命令不碰 DB,不该为它付驱动加载成本。
7
+ */
8
+ export async function createMysqlClient(config) {
9
+ const { createConnection } = await import("mysql2/promise");
10
+ const connection = await createConnection(config);
11
+ return {
12
+ async query(sql, params) {
13
+ const [rows] = await connection.execute(sql, params);
14
+ return Array.isArray(rows) ? rows : [];
15
+ },
16
+ async close() {
17
+ await connection.end();
18
+ },
19
+ };
20
+ }
21
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,125 @@
1
+ import type { ProjectElement, ProjectPage } from "../types.js";
2
+ export interface FieldChange {
3
+ field: string;
4
+ before: unknown;
5
+ after: unknown;
6
+ }
7
+ export interface ChangedElementDiff {
8
+ lookupKey: string;
9
+ id: string | null;
10
+ type: string;
11
+ pagePath: string;
12
+ fieldsChanged: FieldChange[];
13
+ }
14
+ export interface SectionItemRecord {
15
+ lookupKey: string;
16
+ id: string | null;
17
+ path: string;
18
+ sectionKey: string;
19
+ sectionLabel: string;
20
+ itemIndex: number;
21
+ itemLabel: string | null;
22
+ type: unknown;
23
+ width: unknown;
24
+ height: unknown;
25
+ bgColor: unknown;
26
+ bgImageUrl: unknown;
27
+ sheetLocation: unknown;
28
+ elementCount: number;
29
+ bleed: Record<string, unknown> | null;
30
+ backend: Record<string, unknown> | null;
31
+ template: Record<string, unknown> | null;
32
+ flysheetParams: Record<string, unknown> | null;
33
+ }
34
+ export interface ChangedSectionItemDiff {
35
+ lookupKey: string;
36
+ id: string | null;
37
+ path: string;
38
+ sectionKey: string;
39
+ sectionLabel: string;
40
+ itemLabel: string | null;
41
+ type: unknown;
42
+ fieldsChanged: FieldChange[];
43
+ }
44
+ export interface CollectionSectionDiff {
45
+ key: string;
46
+ label: string;
47
+ beforeExists: boolean;
48
+ afterExists: boolean;
49
+ beforeCount: number;
50
+ afterCount: number;
51
+ addedItems: SectionItemRecord[];
52
+ removedItems: SectionItemRecord[];
53
+ changedItems: ChangedSectionItemDiff[];
54
+ summary: {
55
+ addedItemCount: number;
56
+ removedItemCount: number;
57
+ changedItemCount: number;
58
+ };
59
+ }
60
+ export interface CoverSectionDiff extends Omit<CollectionSectionDiff, "summary"> {
61
+ changedFields: FieldChange[];
62
+ summary: {
63
+ changedFieldCount: number;
64
+ addedItemCount: number;
65
+ removedItemCount: number;
66
+ changedItemCount: number;
67
+ };
68
+ }
69
+ export interface PageSectionDiffs {
70
+ pages: CollectionSectionDiff;
71
+ cover: CoverSectionDiff;
72
+ frontFlysheet: CollectionSectionDiff;
73
+ backFlysheet: CollectionSectionDiff;
74
+ edge: CollectionSectionDiff;
75
+ }
76
+ export interface SnapshotSummary {
77
+ projectMeta: Record<string, unknown>;
78
+ imageCount: number;
79
+ pageCount: number;
80
+ elementCount: number;
81
+ pageCollectionCounts: Record<string, number>;
82
+ logicalPageCollectionCounts: Record<string, number>;
83
+ elementTypeCounts: Record<string, number>;
84
+ }
85
+ export interface ProjectDiffResult {
86
+ generatedAt: string;
87
+ beforeSummary: SnapshotSummary;
88
+ afterSummary: SnapshotSummary;
89
+ pageSectionDiffs: PageSectionDiffs;
90
+ changedProjectMeta: FieldChange[];
91
+ addedPages: EnrichedPage[];
92
+ removedPages: EnrichedPage[];
93
+ addedElements: ProjectElement[];
94
+ removedElements: ProjectElement[];
95
+ changedElements: ChangedElementDiff[];
96
+ summary: {
97
+ addedPageCount: number;
98
+ removedPageCount: number;
99
+ addedElementCount: number;
100
+ removedElementCount: number;
101
+ changedElementCount: number;
102
+ changedProjectMetaCount: number;
103
+ };
104
+ }
105
+ interface EnrichedPage extends ProjectPage {
106
+ logicalCollectionName: string;
107
+ logicalCollectionLabel: string;
108
+ logicalItemLabel: string | null;
109
+ }
110
+ interface DiffProjectSnapshotsOptions {
111
+ maxChanges?: number;
112
+ }
113
+ /**
114
+ * 对比两个 project 快照。
115
+ *
116
+ * 输出重点不是“全文 diff”,而是诊断最关心的三类变化:
117
+ * - 页面增删
118
+ * - 元素增删
119
+ * - 元素几何 / crop / 图片引用变化
120
+ *
121
+ * 这样一来,日志里看到 `clickCropImage`、`transformElement`、`saveProject`
122
+ * 之后,就能直接检查这些动作是否真的改变了 project 状态。
123
+ */
124
+ export declare function diffProjectSnapshots(beforeRaw: unknown, afterRaw: unknown, options?: DiffProjectSnapshotsOptions): ProjectDiffResult;
125
+ export {};