From 6676a65954d02fce25f01df478ea84cbac70e11b Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 14 Nov 2026 15:13:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(logging):=20=E6=81=A2=E5=A4=8D=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=BA=8B=E4=BB=B6=E7=9B=AE=E5=BD=95=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=99=A8=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=A2=9E=E9=87=8F=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=9B=9E=E9=80=80=E4=BA=8B=E4=BB=B6=20update.delta.fa?= =?UTF-8?q?llback=5Fselected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/logging/generate-log-events.mjs:从 rust/logging/events/ 生成 rust/crates/diagnostics/src/catalog_generated.rs,--check 只读比较;在未改动的 main 上逐字节一致。去掉已无消费者的 Swift 输出。 - update.delta.fallback_selected(WARN,字段 stage、error):桌面客户端的增量更新 在下载、应用或安装补丁失败、改用完整包时记录。 - README 的检查步骤与现状一致;类型化事件、生成器测试和用法门禁见 Lithe-IDEA#1802。 --- README.md | 3 + .../diagnostics/src/catalog_generated.rs | 13 ++ rust/logging/README.md | 8 +- rust/logging/events/update.json | 20 ++ scripts/logging/generate-log-events.mjs | 184 ++++++++++++++++++ 5 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 scripts/logging/generate-log-events.mjs diff --git a/README.md b/README.md index 26ea6e34..79f121ec 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Lithe 的跨平台应用内核,以及插件契约、插件开发资料和官 | [`rust/logging/`](rust/logging) | 日志事件目录 | | [`shared/plugins/`](shared/plugins) | 插件契约 `contract/v1/`、开发指南 `guides/`、参考 `reference/`、官方 SDK `sdk/{node,java,rust}` 和它们的端到端测试 | | [`scripts/rust-core/`](scripts/rust-core) | Rust Core 注释规范检查(模块文档、英文注释、`unsafe` 安全说明、导出项 Rustdoc) | +| [`scripts/logging/`](scripts/logging) | 日志事件目录生成器,从 `rust/logging/events/` 生成 `rust/crates/diagnostics/src/catalog_generated.rs` | **插件开发者**从 [`shared/plugins/README.md`](shared/plugins/README.md) 开始,不需要克隆 Lithe 应用仓库。 @@ -18,6 +19,7 @@ Lithe 的跨平台应用内核,以及插件契约、插件开发资料和官 ```sh cargo test --manifest-path rust/Cargo.toml --workspace # Core 测试 scripts/rust-core/verify-rust-core-comments.sh # 注释规范 +node scripts/logging/generate-log-events.mjs --check # 日志事件目录与生成表一致 node shared/plugins/scripts/verify-docs.mjs # 契约 $id 与文档链接 node shared/plugins/scripts/sdk-e2e.mjs # 三个 SDK 对当前 Core 的端到端测试 ``` @@ -40,6 +42,7 @@ Lithe's cross-platform application core, and the single source of the plugin con | [`rust/logging/`](rust/logging) | The logging event catalog | | [`shared/plugins/`](shared/plugins) | The plugin contract `contract/v1/`, guides `guides/`, reference `reference/`, the official SDKs `sdk/{node,java,rust}` and their end-to-end tests | | [`scripts/rust-core/`](scripts/rust-core) | The Rust Core comment rules check (module docs, English comments, `unsafe` safety sections, Rustdoc on exported items) | +| [`scripts/logging/`](scripts/logging) | The logging event catalog generator, which turns `rust/logging/events/` into `rust/crates/diagnostics/src/catalog_generated.rs` | **Plugin authors** start at [`shared/plugins/README.md`](shared/plugins/README.md) and never need to clone the Lithe application repository. diff --git a/rust/crates/diagnostics/src/catalog_generated.rs b/rust/crates/diagnostics/src/catalog_generated.rs index fa69a86c..1e61272a 100644 --- a/rust/crates/diagnostics/src/catalog_generated.rs +++ b/rust/crates/diagnostics/src/catalog_generated.rs @@ -2344,6 +2344,19 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "Lithe", }, + EventMetadata { + id: "update.delta.fallback_selected", + channel: "update", + level: LogLevel::Warn, + message: "Delta update {stage} failed, falling back to the full download", + fields: &[ + FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Error the delta step returned before the full download replaced it, keeping its stable code and domain", reason: None }, + FieldMetadata { name: "stage", field_type: FieldType::Token, required: true, description: "Delta step that failed before the full archive replaced it: download, apply or install", reason: None }, + ], + span: None, + rate: 60, + owner: "Lithe", + }, EventMetadata { id: "update.download.interrupted", channel: "update", diff --git a/rust/logging/README.md b/rust/logging/README.md index 58f7e2fe..dcefb806 100644 --- a/rust/logging/README.md +++ b/rust/logging/README.md @@ -121,18 +121,16 @@ Swift 的事件值是**未经脱敏的内存输入**,不能自行序列化落 1. 先调查真实调用,选择根和 owner;没有实际来源不新增事件。 2. 在对应 JSON 中声明稳定 ID、通道模板、英文事实、级别和字段;成对操作优先使用 span。检查隐私和动态段来源。 -3. 运行生成器,核对 Swift 参数和 Rust 字段表;不得手改生成文件。 +3. 运行生成器,核对 Rust 字段表;不得手改生成文件。 4. 接入时使用类型化事件。不再有调用点的事件从目录删除并重新生成。 5. 运行下列检查。生成结果稳定排序、使用 LF,不读取运行时 JSON。 ```sh node scripts/logging/generate-log-events.mjs node scripts/logging/generate-log-events.mjs --check -node --test --test-timeout=60000 scripts/logging/test-log-events.mjs -node scripts/logging/verify-log-usage.mjs ``` -`--check` 只读比较,不修复漂移,有缺失或差异即以非零状态退出。生成器先完成全部目录校验和输出规划,再写第一个文件。Swift target 自动发现 `Logging/` 源目录,不需要在 `Package.swift` 中逐个列出。 +`--check` 只读比较,不修复漂移,有缺失或差异即以非零状态退出。生成器先完成全部目录校验和输出规划,再写第一个文件。它只输出 `rust/crates/diagnostics/src/catalog_generated.rs`;桌面客户端的类型化事件、生成器测试和第十二节的用法门禁尚未恢复,由 [Lithe-IDEA#1802](https://github.com/1lck/Lithe-IDEA/issues/1802) 跟踪。 ## 十一、API 示例 @@ -158,6 +156,8 @@ Rust 通过静态 `EVENTS`(按 ID 排序)和二分查找的 `event_by_id` ## 十二、用法门禁 +本节描述的 `scripts/logging/verify-log-usage.mjs` 目前不在仓库中,恢复由 [Lithe-IDEA#1802](https://github.com/1lck/Lithe-IDEA/issues/1802) 跟踪;恢复前不要把它列进检查步骤。 + `scripts/logging/verify-log-usage.mjs` 扫描 `macos/Sources`、`rust/crates/core/src`、`rust/crates/git-host/src`、`rust/crates/ide-host/src` 和 `rust/crates/remote/src`。排除 `rust/crates/remote/src/server/`、`rust/crates/remote/src/bin/` 和 `rust/crates/ide-host/src/bin/`:它们在远端主机或独立进程中运行,stderr 由宿主进程处理,以后单独迁移。 门禁只扫描生产代码:词法上遮蔽注释和字符串内容,排除 `Tests/`、`tests/`、`tests.rs`、文件名以 `_tests.rs` 结尾的 Rust 文件,以及明确标注 `#[cfg(test)]` 或 `#[cfg(all(test, …))]` 的项。它只检查列出的直接写法,不跨文件追踪别名或动态调用。规则分两类: diff --git a/rust/logging/events/update.json b/rust/logging/events/update.json index 395780a6..7f4e7517 100644 --- a/rust/logging/events/update.json +++ b/rust/logging/events/update.json @@ -2,6 +2,26 @@ "version": 1, "root": "update", "events": [ + { + "id": "update.delta.fallback_selected", + "channel": "update", + "level": "warn", + "message": "Delta update {stage} failed, falling back to the full download", + "fields": { + "stage": { + "type": "token", + "required": true, + "description": "Delta step that failed before the full archive replaced it: download, apply or install" + }, + "error": { + "type": "error", + "required": true, + "description": "Error the delta step returned before the full download replaced it, keeping its stable code and domain" + } + }, + "rate": 60, + "owner": "Lithe" + }, { "id": "update.download.interrupted", "channel": "update", diff --git a/scripts/logging/generate-log-events.mjs b/scripts/logging/generate-log-events.mjs new file mode 100644 index 00000000..0c001864 --- /dev/null +++ b/scripts/logging/generate-log-events.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// Build-time catalog compiler. Complete validation precedes every output write. +import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +export const ROOTS = 'app editor perf lsp vcs run debug terminal remote agent update plugins'.split(' '); +export const TYPES = 'int bool duration bytes id token version path error text'.split(' '); +export const DEFAULT_RATE = 60; +export const GENERIC_DESCRIPTIONS = Object.freeze([ + 'Validated internal identifier', + 'Controlled machine vocabulary', + 'Structured failure', + 'Path redacted before persistence', + 'Monotonic elapsed time', + 'Count or numeric result', + 'Outcome flag', + 'Byte count', + 'Component version', + 'Upstream diagnostic', +]); +const LEVELS = ['debug', 'info', 'warn', 'error']; +const RESERVED = ['seq', 'event', 'op', 'msg', 'ts', 'level']; +const segment = /^[a-z0-9][a-z0-9._-]{0,63}$/; +const safeSegment = value => segment.test(value) && !value.endsWith('.') && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/.test(value); +const fieldName = /^[a-z][a-z0-9_]{0,63}$/; +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const fail = message => { throw new Error(message); }; +const nonempty = (value, where) => { if (typeof value !== 'string' || !value.trim()) fail(`${where}: expected nonempty string`); }; +const keys = (value, allowed, where) => { + if (!object(value)) fail(`${where}: expected object`); + for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`${where}: unknown property ${key}`); +}; +const sorted = entries => entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0); +const pascal = id => id.split(/[._]/).map(word => word[0].toUpperCase() + word.slice(1)).join(''); +const camel = name => name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +const wireNames = (name, field) => field.type === 'duration' ? [`${name}_ms`] : field.type === 'error' ? [name, `${name}_code`, `${name}_domain`] : [name]; + +export function validateCatalog(catalogs) { + const ids = new Set(); + const names = new Set(); + const events = []; + const declarations = []; + for (const catalog of catalogs) { + keys(catalog, ['version', 'root', 'events'], 'catalog'); + if (catalog.version !== 1 || !ROOTS.includes(catalog.root) || !Array.isArray(catalog.events)) fail('invalid catalog version/root/events'); + for (const event of catalog.events) { + keys(event, ['id', 'channel', 'level', 'message', 'fields', 'span', 'rate', 'owner'], catalog.root); + if (typeof event.id !== 'string' || !/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*){2,}$/.test(event.id) || event.id.split('.')[0] !== catalog.root) fail(`invalid event id: ${event.id}`); + if (!LEVELS.includes(event.level)) fail(`${event.id}: invalid level`); + nonempty(event.owner, `${event.id} owner`); + nonempty(event.message, `${event.id} message`); + if (!/^[\x20-\x7e]+$/.test(event.message)) fail(`${event.id}: message must be an English single-line template`); + const rate = event.rate ?? DEFAULT_RATE; + if (!Number.isSafeInteger(rate) || rate < 1 || rate > 60000) fail(`${event.id}: invalid rate`); + if (!object(event.fields)) fail(`${event.id}: expected fields`); + const encoded = new Set(RESERVED); + // Typed host bindings derive camelCase parameters; reserve the names they add. + const parameters = new Set(['operationID', ...(event.span ? ['clock', 'emit', 'state'] : [])]); + for (const [name, field] of Object.entries(event.fields)) { + if (!fieldName.test(name) || RESERVED.includes(name)) fail(`${event.id}: reserved or invalid field ${name}`); + if (parameters.has(camel(name))) fail(`${event.id}: generated parameter collision ${name}`); + parameters.add(camel(name)); + keys(field, ['type', 'required', 'description', 'reason'], `${event.id}/${name}`); + if (!TYPES.includes(field.type)) fail(`${event.id}/${name}: unknown type ${field.type}`); + if (typeof field.required !== 'boolean') fail(`${event.id}/${name}: required must be boolean`); + nonempty(field.description, `${event.id}/${name} description`); + if (GENERIC_DESCRIPTIONS.includes(field.description.trim())) fail(`${event.id}/${name}: generic description must explain this event's field`); + if ([...field.description.trim()].length < 16) fail(`${event.id}/${name}: description must contain at least 16 characters`); + if (field.type === 'text') nonempty(field.reason, `${event.id}/${name}: text requires reason`); + else if (field.reason !== undefined) fail(`${event.id}/${name}: reason is only for text`); + if (field.type === 'duration' && name.endsWith('_ms')) fail(`${event.id}/${name}: duration name must not end in _ms`); + for (const wire of wireNames(name, field)) { + if (!fieldName.test(wire) || encoded.has(wire)) fail(`${event.id}: encoded field collision or invalid name ${wire}`); + encoded.add(wire); + } + } + if (typeof event.channel !== 'string') fail(`${event.id}: missing channel`); + const parts = event.channel.split('/'); + if (!ROOTS.includes(parts[0]) || parts[0] !== catalog.root) fail(`${event.id}: invalid channel root`); + if (!(parts.length === 1 || (parts.length === 2 && parts[0] !== 'plugins') || (parts.length === 3 && parts[0] === 'plugins' && parts[2] === 'host'))) fail(`${event.id}: unsupported channel shape`); + for (const part of parts.slice(1)) { + const placeholder = /^\{([a-z][a-z0-9_]*)\}$/.exec(part); + if (placeholder) { + const field = event.fields[placeholder[1]]; + if (field?.type !== 'id' || !field.required) fail(`${event.id}: channel placeholder must be a required id field`); + } else if (!safeSegment(part)) fail(`${event.id}: invalid channel segment`); + } + if (parts[0] === 'plugins' && parts.length > 1 && event.channel !== 'plugins/{plugin}/host') fail(`${event.id}: plugin host channel must be plugins/{plugin}/host`); + for (const match of event.message.matchAll(/\{([^{}]+)\}/g)) { + if (!Object.hasOwn(event.fields, match[1])) fail(`${event.id}: message references undeclared field ${match[1]}`); + if (!event.fields[match[1]].required) fail(`${event.id}: message references optional field ${match[1]}`); + } + if (/[{}]/.test(event.message.replace(/\{[^{}]+\}/g, ''))) fail(`${event.id}: malformed message template`); + if (event.span !== undefined) { + keys(event.span, ['failureLevel'], `${event.id} span`); + if (!LEVELS.includes(event.span.failureLevel)) fail(`${event.id}: invalid span failureLevel`); + if (['duration', 'error'].some(name => Object.hasOwn(event.fields, name)) || ['duration_ms', 'error', 'error_code', 'error_domain'].some(name => encoded.has(name))) fail(`${event.id}: span automatic fields collide`); + } else if (!/(?:started|finished|failed|changed|skipped|missing|received|selected|superseded|hit|inspected|sampled|measured|rejected|updated|forwarded|removed|cleaned|cancelled|initialized|limited|applied|waiting|stalled|timed_out|resolved|logged|shown|interrupted|traced|performed)$/.test(event.id.split('.').at(-1))) fail(`${event.id}: expected outcome name in past tense`); + declarations.push({ ...event, rate }); + const variants = event.span ? ['started', 'finished', 'failed'] : [null]; + for (const phase of variants) { + const fields = { ...event.fields }; + if (phase && phase !== 'started') fields.duration = { type: 'duration', required: true, description: `Monotonic time from ${event.id}.started to ${event.id}.${phase}, encoded in milliseconds` }; + if (phase === 'failed') fields.error = { type: 'error', required: true, description: `Failure that ended the ${event.id} operation, retaining its error code and domain without unredacted private details` }; + const expanded = { ...event, id: phase ? `${event.id}.${phase}` : event.id, + fields: Object.fromEntries(sorted(Object.entries(fields))), rate, span: phase, + message: phase ? `${event.message} ${phase}` : event.message, + level: phase === 'started' ? 'debug' : phase === 'failed' ? event.span.failureLevel : event.level }; + const name = pascal(expanded.id); + if (ids.has(expanded.id)) fail(`duplicate id: ${expanded.id}`); + if (names.has(name)) fail(`generated name collision: ${name}`); + ids.add(expanded.id); names.add(name); events.push(expanded); + } + if (event.span) { + const name = `${pascal(event.id)}Span`; + if (names.has(name)) fail(`generated name collision: ${name}`); + names.add(name); + } + } + } + return { events: events.sort((a, b) => a.id < b.id ? -1 : 1), declarations: declarations.sort((a, b) => a.id < b.id ? -1 : 1) }; +} + +export async function loadCatalog(root = ROOT) { + const directory = path.join(root, 'rust/logging/events'); + const files = (await readdir(directory)).sort(); + if (JSON.stringify(files) !== JSON.stringify(ROOTS.map(root => `${root}.json`).sort())) fail('expected exactly one catalog file per fixed channel root'); + const catalogs = await Promise.all(files.map(async file => { + const catalog = JSON.parse(await readFile(path.join(directory, file), 'utf8')); + if (catalog.root !== file.slice(0, -5)) fail(`${file}: root differs from filename`); + return catalog; + })); + return validateCatalog(catalogs); +} + +const quote = JSON.stringify; +function rustEvents(events) { + const variant = type => pascal(type); + return `//! Generated event metadata; edit rust/logging/events/*.json instead.\n// Automatically generated by scripts/logging/generate-log-events.mjs. DO NOT EDIT.\n\nuse super::{EventMetadata, FieldMetadata, FieldType, LogLevel, SpanPhase};\n\n/// Sorted, immutable metadata for every expanded catalog event.\n#[rustfmt::skip]\npub static EVENTS: &[EventMetadata] = &[\n${events.map(event => ` EventMetadata { + id: ${quote(event.id)}, + channel: ${quote(event.channel)}, + level: LogLevel::${variant(event.level)}, + message: ${quote(event.message)}, + fields: &[ +${Object.entries(event.fields).map(([name, field]) => ` FieldMetadata { name: ${quote(name)}, field_type: FieldType::${variant(field.type)}, required: ${field.required}, description: ${quote(field.description)}, reason: ${field.reason ? `Some(${quote(field.reason)})` : 'None'} },`).join('\n')} + ], + span: ${event.span ? `Some(SpanPhase::${variant(event.span)})` : 'None'}, + rate: ${event.rate}, + owner: ${quote(event.owner)}, + },`).join('\n')}\n];\n`; +} +export async function planGeneration(root = ROOT) { + const contract = await loadCatalog(root); + const outputs = new Map([ + ['rust/crates/diagnostics/src/catalog_generated.rs', rustEvents(contract.events)], + ]); + return { contract, outputs }; +} +export async function generate(root = ROOT, check = false) { + const { contract, outputs } = await planGeneration(root); + const stale = []; + for (const [relative, expected] of outputs) { + let actual; + try { actual = await readFile(path.join(root, relative), 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; } + if (actual === expected) continue; + stale.push(relative); + if (!check) { + await mkdir(path.dirname(path.join(root, relative)), { recursive: true }); + await writeFile(path.join(root, relative), expected); + } + } + if (check && stale.length) fail(`generated logging files drifted; run node scripts/logging/generate-log-events.mjs:\n${stale.join('\n')}`); + return contract; +} +async function main() { + const args = process.argv.slice(2); + if (args.length > 1 || args.some(arg => arg !== '--check')) fail('usage: node scripts/logging/generate-log-events.mjs [--check]'); + const { events } = await generate(ROOT, args[0] === '--check'); + console.log(`${events.length} log events: ${ROOTS.map(root => `${root}=${events.filter(e => e.id.startsWith(root + '.')).length}`).join(', ')}`); +} +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(path.resolve(process.argv[1]))).href) main().catch(error => { console.error(error.message); process.exitCode = 1; });