@aiti-ai/aiti-cmd 0.0.0-stage → 0.1.9

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/AGENTS.md ADDED
@@ -0,0 +1,28 @@
1
+ # aiti-cmd 扩展(部署目录直改指南)
2
+
3
+ ## 结构
4
+ - 运行时文件平铺在本目录根(index.ts / core.cjs …,宿主 loader 按相对路径加载——**禁手改运行时平铺文件**,改 src/ 再 build)
5
+ - `src/` 源码(client/src + 服务端 ts/cjs)· `dist/` 构建产物(禁手改)· `tools/` 构建/部署脚本
6
+ - npm 包形态的扩展:本目录 package.json 含 version 与 aitiConfigs 声明
7
+
8
+ ## 小改现场流程(改文案/样式/小逻辑)
9
+ 1. 改 `src/` 下对应源码
10
+ 2. `node tools/build.mjs`(client 有构建产物的扩展;纯服务端改动跳过)
11
+ 3. `curl -X POST http://127.0.0.1:3001/api/extensions/reload`(dev;prod 3002;外部经 nginx 带 basePath:/dev、/mac、/mdev、/win、/wdev → `/xxx/api/extensions/reload`)
12
+ 4. 浏览器刷新页面验证
13
+
14
+ ## 大改回源码仓
15
+ - 源码仓:`extensions/aiti-cmd/`(git 管理,deploy 同步回本目录)
16
+ - 改完在源码仓跑 `node tools/deploy.mjs`(或仓库根 `node scripts/deploy-all.mjs --only aiti-cmd`)
17
+
18
+ ## 数据目录(数据分层:config / cache)
19
+ - `ctx.configDir` 根级 = 配置与关键小状态(aiti-configs 备份范围;小、珍贵、人可读)
20
+ - `ctx.cacheDir`(= `<configDir>/cache/`)= 可删缓存:db/venv/日志/大文件;rm -rf 后扩展下次访问自动重建
21
+ - `ctx.dataDir` 已移除(访问即抛)——新代码禁用
22
+ - 本扩展白名单(永不为 cache 的配置文件)见 package.json `aitiConfigs.configFiles`
23
+
24
+ ## 红线
25
+ - 禁轮询(setInterval 拉取);需要等待用事件/回调/SSE(桥的 SSE、aiti-event-bus topics)
26
+ - 禁重启生产服务(pi-web 3002 等 restart 须用户明确同意)
27
+ - 禁手改 dist/ 与运行时平铺文件;跨扩展通信走 ctx.services(禁 globalThis.__ext 直穿)
28
+ - 服务端:SQLite 一律 WAL + 分批小事务(批间 setImmediate 让步);禁 execFileSync 调 >1s 外部程序;>8MB 文件禁同步读
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aiti-ai contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/audit.ts ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * aiti-cmd · 执行审计 —— 仿 pi 的 JSONL 全量留存(安全审计)
3
+ *
4
+ * 所有执行逐条 append-only 落盘到 <ctx.cacheDir>/logs/<YYYY-MM-DD>.jsonl(= ~/.pi/web/data/aiti-cmd/cache/logs/),
5
+ * 按天分片、追加写、历史不可改(权限 600)。输出另存 sidecar 纯文本文件
6
+ * (去 ANSI;>1MB 截断 + outputTruncated 标记 + 保留尾部)。
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { createHash } from "node:crypto";
11
+
12
+ /** 日志保留天数(按天分片文件超期清理) */
13
+ const RETENTION_DAYS = parseInt(process.env.AITI_CMD_RETENTION_DAYS || "90", 10) || 90;
14
+
15
+ export interface AuditRecord {
16
+ ts: number;
17
+ id: string;
18
+ kind: "exec";
19
+ /** 触发来源——开放语义(aiti-cmd-exec-integration-plan.md §3.1):新入口可传新值,未知值在
20
+ * POST /run 收口为 "unknown"。基线枚举(只作参照不封闭):
21
+ * manual | message | shell | favorite | asr-selfheal | schedule | suggest | ai-helper | codebuddy | service | unknown */
22
+ source: string;
23
+ /** true = POST /run 未带 source 被兜底(不再静默吞成 manual,防新入口漏传,§3.1) */
24
+ sourceDefaulted?: boolean;
25
+ /** 触发来源:pi 会话 id(消息识别执行时关联) */
26
+ piSessionId?: string;
27
+ cwd?: string;
28
+ /** 完整命令(参数已填充) */
29
+ cmd: string;
30
+ /** 参数(运行前填写的占位符值) */
31
+ params?: Record<string, string>;
32
+ /** 环境变量名(值不落盘——脱敏) */
33
+ envNames?: string[];
34
+ /** 命中危险模式 */
35
+ danger?: string[];
36
+ confirm?: boolean;
37
+ /** 沙箱模式 */
38
+ sandbox?: string;
39
+ pid?: number;
40
+ exit?: number | null;
41
+ signal?: string | null;
42
+ durationMs?: number;
43
+ outBytes?: number;
44
+ outputTruncated?: boolean;
45
+ outputFile?: string;
46
+ /** 哈希链(防篡改):sha256(prevHash + 本记录无 hash 字段的规范序列化) */
47
+ hash?: string;
48
+ }
49
+
50
+ export interface AuditQuery {
51
+ from?: number;
52
+ to?: number;
53
+ source?: string;
54
+ exit?: string;
55
+ cmd?: string;
56
+ limit?: number;
57
+ }
58
+
59
+ export function createAudit(cacheDir: string) {
60
+ const logsDir = path.join(cacheDir, "logs");
61
+ fs.mkdirSync(logsDir, { recursive: true, mode: 0o700 });
62
+
63
+ const todayFile = () => path.join(logsDir, new Date().toISOString().slice(0, 10) + ".jsonl");
64
+
65
+ // ---- 哈希链(防篡改):lastHash 在进程内随 append 推进;启动时从现有文件尾部重建 ----
66
+ let lastHash = "";
67
+ function rebuildChain() {
68
+ lastHash = "";
69
+ for (const f of listFiles()) {
70
+ try {
71
+ const lines = fs.readFileSync(path.join(logsDir, f), "utf8").split("\n");
72
+ for (const line of lines) {
73
+ if (!line.trim()) continue;
74
+ try {
75
+ const rec = JSON.parse(line) as AuditRecord;
76
+ if (rec.hash) lastHash = rec.hash;
77
+ } catch { /* skip bad line */ }
78
+ }
79
+ } catch { /* skip file */ }
80
+ }
81
+ }
82
+ function chainHash(prev: string, rec: AuditRecord): string {
83
+ const { hash: _h, ...canon } = rec; // 规范序列化(去掉 hash 自身,防止自引用)
84
+ return createHash("sha256").update(prev + JSON.stringify(canon)).digest("hex");
85
+ }
86
+ rebuildChain();
87
+
88
+ function append(rec: AuditRecord) {
89
+ try {
90
+ rec.hash = chainHash(lastHash, rec);
91
+ lastHash = rec.hash;
92
+ fs.appendFileSync(todayFile(), JSON.stringify(rec) + "\n", { mode: 0o600 });
93
+ } catch (e) {
94
+ console.error("[aiti-cmd] audit append failed:", e);
95
+ }
96
+ }
97
+
98
+ /** 校验哈希链:从第一条带 hash 的记录向后重放;篡改/缺失 → 返回损坏点 */
99
+ function verify(): { ok: boolean; count: number; legacy: number; brokenAt?: string; checkedHashes: number } {
100
+ let prev = "";
101
+ let count = 0, legacy = 0, checked = 0;
102
+ let brokenAt: string | undefined;
103
+ for (const f of listFiles()) {
104
+ try {
105
+ const lines = fs.readFileSync(path.join(logsDir, f), "utf8").split("\n");
106
+ for (const line of lines) {
107
+ if (!line.trim()) continue;
108
+ try {
109
+ const rec = JSON.parse(line) as AuditRecord;
110
+ count++;
111
+ if (!rec.hash) { legacy++; continue; } // 升级前 legacy 记录不参与校验
112
+ const expected = chainHash(prev, rec);
113
+ if (expected !== rec.hash) { brokenAt = rec.id; break; }
114
+ prev = rec.hash;
115
+ checked++;
116
+ } catch { /* skip bad line */ }
117
+ }
118
+ if (brokenAt) break;
119
+ } catch { /* skip file */ }
120
+ }
121
+ return { ok: !brokenAt, count, legacy, brokenAt, checkedHashes: checked };
122
+ }
123
+
124
+ /** 保留策略:删除超过保留天数的日志文件(.jsonl + .out.txt),返回删除数 */
125
+ function cleanupRetention(): number {
126
+ let removed = 0;
127
+ const cutoff = Date.now() - RETENTION_DAYS * 24 * 3600 * 1000;
128
+ try {
129
+ const files = fs.readdirSync(logsDir);
130
+ for (const f of files) {
131
+ // 文件日期:jsonl 取文件名前缀(YYYY-MM-DD);out.txt 取对应 id 记录的日期(简化:按 mtime)
132
+ let dateStr: string | null = null;
133
+ const m = f.match(/^(\d{4}-\d{2}-\d{2})\.jsonl$/);
134
+ if (m) dateStr = m[1];
135
+ else {
136
+ try { dateStr = new Date(fs.statSync(path.join(logsDir, f)).mtime).toISOString().slice(0, 10); } catch { continue; }
137
+ }
138
+ const fileTs = Date.parse(dateStr);
139
+ if (!isNaN(fileTs) && fileTs < cutoff) {
140
+ try { fs.unlinkSync(path.join(logsDir, f)); removed++; } catch {}
141
+ }
142
+ }
143
+ } catch {}
144
+ return removed;
145
+ }
146
+
147
+
148
+ /** 执行输出落盘:去 ANSI 纯文本,>1MB 截断保留尾部(>=MAX 字节则输出尾部 MAX*0.4) */
149
+ function writeOutput(id: string, raw: string): { outBytes: number; outputTruncated: boolean; outputFile: string } {
150
+ const clean = stripAnsi(raw);
151
+ const bytes = Buffer.byteLength(clean, "utf8");
152
+ const MAX = 1024 * 1024;
153
+ const outputTruncated = bytes > MAX;
154
+ const final = outputTruncated ? clean.slice(-Math.floor(MAX * 0.6)) : clean;
155
+ const outputFile = path.join(logsDir, `${id}.out.txt`);
156
+ try {
157
+ fs.writeFileSync(outputFile, final, { mode: 0o600 });
158
+ } catch (e) {
159
+ console.error("[aiti-cmd] audit output write failed:", e);
160
+ }
161
+ return { outBytes: bytes, outputTruncated, outputFile };
162
+ }
163
+
164
+ function readOutput(id: string): string | null {
165
+ try {
166
+ const p = path.join(logsDir, `${id}.out.txt`);
167
+ if (!fs.existsSync(p)) return null;
168
+ return fs.readFileSync(p, "utf8");
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function listFiles(): string[] {
175
+ try {
176
+ return fs.readdirSync(logsDir).filter((f) => f.endsWith(".jsonl")).sort();
177
+ } catch {
178
+ return [];
179
+ }
180
+ }
181
+
182
+ function query(q: AuditQuery = {}): AuditRecord[] {
183
+ const out: AuditRecord[] = [];
184
+ const files = listFiles();
185
+ for (const f of files) {
186
+ try {
187
+ const lines = fs.readFileSync(path.join(logsDir, f), "utf8").split("\n");
188
+ for (const line of lines) {
189
+ if (!line.trim()) continue;
190
+ try {
191
+ const rec = JSON.parse(line) as AuditRecord;
192
+ if (q.from && rec.ts < q.from) continue;
193
+ if (q.to && rec.ts > q.to) continue;
194
+ if (q.source && rec.source !== q.source) continue;
195
+ if (q.exit !== undefined && rec.exit !== Number(q.exit)) continue;
196
+ if (q.cmd && !rec.cmd.includes(q.cmd)) continue;
197
+ out.push(rec);
198
+ } catch { /* skip bad line */ }
199
+ }
200
+ } catch { /* skip file */ }
201
+ }
202
+ out.sort((a, b) => b.ts - a.ts);
203
+ const limit = Math.min(Math.max(parseInt(String(q.limit || 100), 10) || 100, 1), 1000);
204
+ return out.slice(0, limit);
205
+ }
206
+
207
+ /** 按 source 汇总(§3.1 /history?groupBy=source 数据源):全量扫描聚合数量/最近时间/失败率 */
208
+ function summarizeBySource(): { source: string; count: number; lastTs: number; failCount: number; failRate: number }[] {
209
+ const acc = new Map<string, { count: number; lastTs: number; failCount: number }>();
210
+ for (const f of listFiles()) {
211
+ try {
212
+ const lines = fs.readFileSync(path.join(logsDir, f), "utf8").split("\n");
213
+ for (const line of lines) {
214
+ if (!line.trim()) continue;
215
+ try {
216
+ const rec = JSON.parse(line) as AuditRecord;
217
+ const src = rec.source || "unknown";
218
+ let a = acc.get(src);
219
+ if (!a) { a = { count: 0, lastTs: 0, failCount: 0 }; acc.set(src, a); }
220
+ a.count++;
221
+ if (rec.ts > a.lastTs) a.lastTs = rec.ts;
222
+ // 失败语义与查询一致:exit 非 0(含信号杀 128+n)计失败;exit null(排队取消/在途)不计
223
+ if (typeof rec.exit === "number" && rec.exit !== 0) a.failCount++;
224
+ } catch { /* skip bad line */ }
225
+ }
226
+ } catch { /* skip file */ }
227
+ }
228
+ return [...acc.entries()]
229
+ .map(([source, a]) => ({ source, count: a.count, lastTs: a.lastTs, failCount: a.failCount, failRate: a.count ? a.failCount / a.count : 0 }))
230
+ .sort((x, y) => y.count - x.count || y.lastTs - x.lastTs);
231
+ }
232
+
233
+ function getById(id: string): AuditRecord | null { for (const f of listFiles()) {
234
+ try {
235
+ const lines = fs.readFileSync(path.join(logsDir, f), "utf8").split("\n");
236
+ for (const line of lines) {
237
+ if (!line.trim()) continue;
238
+ try {
239
+ const rec = JSON.parse(line) as AuditRecord;
240
+ if (rec.id === id) return rec;
241
+ } catch { /* skip */ }
242
+ }
243
+ } catch { /* skip */ }
244
+ }
245
+ return null;
246
+ }
247
+
248
+ return { append, writeOutput, readOutput, query, summarizeBySource, getById, verify, cleanupRetention, logsDir, retentionDays: RETENTION_DAYS };
249
+ }
250
+
251
+ // ---- ANSI 剥离(轻量,覆盖 SGR/光标/清屏等常见序列) ----
252
+ const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
253
+ function stripAnsi(text: string): string {
254
+ return String(text).replace(ANSI_RE, "");
255
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * check-client —— 扩展 client .mjs/.js 的 TS 残留 tripwire(零依赖,纯 node)。
3
+ *
4
+ * 背景:aiti-plan-review 事故(docs/aiti-ext-client-hardening-plan.md §1)——
5
+ * `useState<any>(null)` 解析为合法 JS 比较表达式 `useState < any > (null)`,
6
+ * node --check / TS 解析器 / babel 全不报错,只有运行时才炸。纯语法检查
7
+ * 原理性无效,只能启发式规则 + 渲染期错误边界兜底。
8
+ *
9
+ * 用法:
10
+ * CLI node check-client.mjs <file|dir>... FAIL → exit 1
11
+ * 自测 node check-client.mjs --selftest fixtures 全过 → exit 0
12
+ * API lintClientSource(src) → violations[] { line, level, rule, text }
13
+ * lintPath(path) → { files, violations }(文件或目录,递归)
14
+ * checkClientDir(dir,{log}) FAIL → throw(deploy 脚本用)
15
+ *
16
+ * 词法:规则匹配前先剥离注释/字符串/模板字面量(${} 插值内代码保留检查)——
17
+ * aiti-editor 的 Monaco TS stub 模板串、JSDoc @return 都是合法内容,不剥全误报。
18
+ * 已知极限:regex 字面量含引号等 lexer 歧义允许漏报(tripwire 定位非编译器)。
19
+ *
20
+ * 豁免:目标行或其上一行含 `check-client:allow` 即跳过该行。
21
+ * 权威源:本文件;aiti-extman 部署副本由其 tools/deploy.mjs 拷贝(改动两处同步)。
22
+ */
23
+ import { readdirSync, readFileSync, statSync } from "node:fs";
24
+ import { join, resolve } from "node:path";
25
+
26
+ // ── 规则(分级)──────────────────────────────────────────────────────────────
27
+
28
+ const RULES = [
29
+ // FAIL:合法 JS 但运行时必炸 / 纯 TS 语法
30
+ { level: "FAIL", re: /\b(useState|useRef|useMemo|useCallback|useReducer|useImperativeHandle)\s*</, why: "React hook 泛型(运行时必炸)" },
31
+ // 注:含「any/unknown/string」字样的规则用字符串构 RegExp——本文件自身也
32
+ // 会被扫,正则字面量里的 (?:any 会被自己命中(词法器不处理 regex 字面量),
33
+ // 字符串字面量则会被词法遮蔽。
34
+ { level: "FAIL", re: new RegExp("\\bas\\s+(?:" + "any|unknown|const)\\b"), why: "as 断言" },
35
+ { level: "FAIL", re: new RegExp(":\\s*(?:" + "any|unknown)\\b"), why: ": any/: unknown 注解" },
36
+ { level: "FAIL", re: /\b(?:import|export)\s+type\s/, why: "import/export type" },
37
+ { level: "FAIL", re: /\b(?:interface|enum)\s+[A-Za-z_$]/, why: "interface/enum 声明" },
38
+ // warn:疑似残留,人工确认,不阻断
39
+ { level: "warn", re: new RegExp(":\\s*(?:" + "string|number|boolean|void)\\b(?!\\s*[._\\w])"), why: "疑似类型注解" },
40
+ { level: "warn", re: /\bsatisfies\s/, why: "satisfies" },
41
+ { level: "warn", re: /\b(?:Record|Partial|Readonly|Omit|Pick)\s*</, why: "TS 工具类型" },
42
+ ];
43
+
44
+ // ── 词法剥离:注释/字符串/模板字面量 → 空格(保留换行,行号不漂移)─────────
45
+ export function stripLiterals(src) {
46
+ const out = src.split("");
47
+ const n = src.length;
48
+ const mask = (from, to) => {
49
+ for (let k = from; k < to && k < n; k++) if (out[k] !== "\n") out[k] = " ";
50
+ };
51
+ let mode = "code";
52
+ let depth = 0;
53
+ const stack = []; // 模板 ${ 插值进入时的 brace 深度(支持嵌套模板)
54
+ let i = 0;
55
+ while (i < n) {
56
+ const c = src[i];
57
+ const d = i + 1 < n ? src[i + 1] : "";
58
+ if (mode === "code") {
59
+ if (c === "/" && d === "/") {
60
+ const j = src.indexOf("\n", i);
61
+ const end = j === -1 ? n : j;
62
+ mask(i, end);
63
+ i = end;
64
+ } else if (c === "/" && d === "*") {
65
+ const j = src.indexOf("*/", i + 2);
66
+ const end = j === -1 ? n : j + 2;
67
+ mask(i, end);
68
+ i = end;
69
+ } else if (c === "'" || c === '"') {
70
+ let j = i + 1;
71
+ while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1;
72
+ if (j < n && src[j] === c) {
73
+ mask(i, j + 1);
74
+ i = j + 1;
75
+ } else {
76
+ // 未闭合到行尾 → 可能是 regex 字面量歧义,只遮开引号,按代码继续
77
+ out[i] = " ";
78
+ i++;
79
+ }
80
+ } else if (c === "`") {
81
+ mode = "tpl";
82
+ out[i] = " ";
83
+ i++;
84
+ } else if (c === "{") {
85
+ depth++;
86
+ i++;
87
+ } else if (c === "}") {
88
+ if (stack.length && depth === stack[stack.length - 1]) {
89
+ stack.pop();
90
+ mode = "tpl"; // 插值结束回模板
91
+ out[i] = " ";
92
+ } else {
93
+ depth = Math.max(0, depth - 1);
94
+ }
95
+ i++;
96
+ } else i++;
97
+ } else if (mode === "tpl") {
98
+ if (c === "\\") {
99
+ mask(i, Math.min(i + 2, n));
100
+ i += 2;
101
+ } else if (c === "`") {
102
+ mode = "code";
103
+ out[i] = " ";
104
+ i++;
105
+ } else if (c === "$" && d === "{") {
106
+ stack.push(depth);
107
+ mode = "code";
108
+ mask(i, i + 2);
109
+ i += 2;
110
+ } else {
111
+ if (c !== "\n") out[i] = " ";
112
+ i++;
113
+ }
114
+ } else {
115
+ // 不可达:line/block/字符串均在 code 分支内联处理
116
+ i++;
117
+ }
118
+ }
119
+ return out.join("");
120
+ }
121
+
122
+ // ── 单源检查 ────────────────────────────────────────────────────────────────
123
+ export function lintClientSource(src) {
124
+ const masked = stripLiterals(src);
125
+ const origLines = src.split("\n");
126
+ const maskedLines = masked.split("\n");
127
+ const violations = [];
128
+ for (let i = 0; i < maskedLines.length; i++) {
129
+ const prev = i > 0 ? origLines[i - 1] : "";
130
+ if (origLines[i].includes("check-client:allow") || prev.includes("check-client:allow")) continue;
131
+ for (const { level, re, why } of RULES) {
132
+ if (re.test(maskedLines[i])) {
133
+ violations.push({ line: i + 1, level, rule: why, text: origLines[i].trim().slice(0, 90) });
134
+ }
135
+ }
136
+ }
137
+ return violations;
138
+ }
139
+
140
+ // ── 文件/目录检查 ───────────────────────────────────────────────────────────
141
+ const SKIP_DIR = new Set(["node_modules", ".git"]);
142
+ /** 超过此体积视为 vendored bundle(如 Monaco ts.worker,合法含 interface 字样),不查。 */
143
+ const MAX_BYTES = 512 * 1024;
144
+ const isClientFile = (name) => /\.(mjs|js|cjs)$/.test(name) && !/\.min\./.test(name) && !/\.test\./.test(name);
145
+
146
+ export function lintPath(p, out = { files: 0, violations: [] }) {
147
+ const st = statSync(p);
148
+ if (st.isDirectory()) {
149
+ for (const name of readdirSync(p)) {
150
+ if (SKIP_DIR.has(name)) continue;
151
+ lintPath(join(p, name), out);
152
+ }
153
+ return out;
154
+ }
155
+ const base = p.split("/").pop();
156
+ if (!isClientFile(base)) return out;
157
+ if (st.size > MAX_BYTES) return out; // vendored bundle 豁免
158
+ out.files++;
159
+ for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
160
+ return out;
161
+ }
162
+
163
+ export function formatViolations(violations) {
164
+ return violations
165
+ .map((v) => ` ${v.level} ${v.file}:${v.line} [${v.rule}] ${v.text}`)
166
+ .join("\n");
167
+ }
168
+
169
+ /** deploy 脚本用:检查目录,FAIL 抛错(消息含违规清单),warn 只打日志不阻断。 */
170
+ export function checkClientDir(dir, { log = console.log } = {}) {
171
+ const { files, violations } = lintPath(resolve(dir));
172
+ const fails = violations.filter((v) => v.level === "FAIL");
173
+ const warns = violations.filter((v) => v.level === "warn");
174
+ if (warns.length) log(`[check-client] ${warns.length} 处疑似 TS 残留(不阻断,建议人工确认):\n${formatViolations(warns)}`);
175
+ if (fails.length) {
176
+ throw new Error(
177
+ `client 检查未通过:${files} 个文件中 ${fails.length} 处 TS 残留(.mjs 里是合法 JS,语法检查不报,运行时必炸)。\n` +
178
+ formatViolations(fails) +
179
+ `\n修复或在该行加注释 // check-client:allow 豁免。详见 docs/aiti-ext-client-hardening-plan.md`,
180
+ );
181
+ }
182
+ if (files) log(`[check-client] ${files} 个 client 文件检查通过`);
183
+ return { files, fails: fails.length, warns: warns.length };
184
+ }
185
+
186
+ /**
187
+ * extman 安装用:扫整个扩展目录(含第三方,不遵循 client/ 约定)。
188
+ * 在 SKIP_DIR 之上额外跳过构建产物/vendored 目录(Monaco ts.worker、语言包等
189
+ * 合法包含 TS 字样,词法器对 minified 代码不提供保证);体积上限同上。
190
+ */
191
+ export function lintExtensionDir(dir) {
192
+ const out = { files: 0, violations: [] };
193
+ const walk = (p) => {
194
+ const st = statSync(p);
195
+ if (st.isDirectory()) {
196
+ for (const name of readdirSync(p)) {
197
+ if (SKIP_DIR.has(name) || VENDORED_DIRS.has(name)) continue;
198
+ walk(join(p, name));
199
+ }
200
+ return;
201
+ }
202
+ const base = p.split("/").pop();
203
+ if (!isClientFile(base) || st.size > MAX_BYTES) return;
204
+ out.files++;
205
+ for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
206
+ };
207
+ walk(resolve(dir));
208
+ return out;
209
+ }
210
+ const VENDORED_DIRS = new Set(["vendor", "monaco", "dist", "build", "assets", "static", "bundles", "chunks"]);
211
+
212
+ // ── CLI ─────────────────────────────────────────────────────────────────────
213
+ function selftest() {
214
+ const MUST_FAIL = [
215
+ "const [st, setSt] = useState<any>(null);",
216
+ "const r = useRef<number>(null);",
217
+ "const x = v as any;",
218
+ "function f(a: unknown) {}",
219
+ 'import type { Foo } from "./x";',
220
+ "interface Foo { a: string }",
221
+ "enum Color { Red }",
222
+ "const y = v as const;",
223
+ ];
224
+ const MUST_WARN = [
225
+ "const opts = { mode: string };",
226
+ "x satisfies Y;",
227
+ "const m: Record<string, any> = {};",
228
+ ];
229
+ const MUST_PASS = [
230
+ 'const REACT_TYPE_STUB = `\ndeclare module "react" {\n export function useState<T>(init: T): [T];\n export const Fragment: any;\n}\n`;',
231
+ "/** @return {{chars1: string, chars2: string}} */",
232
+ "const [s] = useState(null);",
233
+ "useState<any>(null); // check-client:allow",
234
+ "// check-client:allow\nuseState<never>(0);",
235
+ "const cmp = a < b && c > (d);",
236
+ "if (a // 注释里 as any 不算\n ) run();",
237
+ ];
238
+ let bad = 0;
239
+ const check = (src, expect, desc) => {
240
+ const v = lintClientSource(src);
241
+ const fails = v.filter((x) => x.level === "FAIL");
242
+ const ok = expect === "FAIL" ? fails.length > 0 : expect === "PASS" ? v.length === 0 : v.length > 0 && fails.length === 0;
243
+ if (!ok) {
244
+ bad++;
245
+ console.log(`SELFTEST FAIL [${desc}] 期望 ${expect},实际 ${JSON.stringify(v)}`);
246
+ }
247
+ };
248
+ MUST_FAIL.forEach((s) => check(s, "FAIL", s.slice(0, 40)));
249
+ MUST_WARN.forEach((s) => check(s, "WARN", s.slice(0, 40)));
250
+ MUST_PASS.forEach((s) => check(s, "PASS", s.replace(/\n/g, "⏎").slice(0, 40)));
251
+ if (bad) {
252
+ console.log(`selftest: ${bad} 处未过`);
253
+ process.exit(1);
254
+ }
255
+ console.log("selftest: 全部通过(8 FAIL / 3 WARN / 7 PASS)");
256
+ }
257
+
258
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
259
+ if (process.argv.includes("--selftest")) {
260
+ selftest();
261
+ } else if (process.argv.length > 2) {
262
+ const all = { files: 0, violations: [] };
263
+ for (const p of process.argv.slice(2)) lintPath(resolve(p), all);
264
+ const fails = all.violations.filter((v) => v.level === "FAIL");
265
+ const warns = all.violations.filter((v) => v.level === "warn");
266
+ if (all.violations.length) console.log(formatViolations(all.violations));
267
+ console.log(`\n${all.files} 个文件:FAIL=${fails.length} warn=${warns.length}`);
268
+ process.exit(fails.length ? 1 : 0);
269
+ } else {
270
+ console.log("用法:node check-client.mjs <file|dir>... | --selftest");
271
+ process.exit(2);
272
+ }
273
+ }