Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 应用仓库。

Expand All @@ -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 的端到端测试
```
Expand All @@ -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.

Expand Down
13 changes: 13 additions & 0 deletions rust/crates/diagnostics/src/catalog_generated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions rust/logging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 示例

Expand All @@ -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, …))]` 的项。它只检查列出的直接写法,不跨文件追踪别名或动态调用。规则分两类:
Expand Down
20 changes: 20 additions & 0 deletions rust/logging/events/update.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
184 changes: 184 additions & 0 deletions scripts/logging/generate-log-events.mjs
Original file line number Diff line number Diff line change
@@ -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; });
Loading