@vobs/cli 1.7.7 → 1.8.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 (81) hide show
  1. package/dist/banner.cjs +22 -3
  2. package/dist/banner.cjs.map +1 -1
  3. package/dist/banner.d.cts +6 -2
  4. package/dist/banner.d.ts +6 -2
  5. package/dist/banner.js +20 -3
  6. package/dist/banner.js.map +1 -1
  7. package/dist/cli.cjs +364 -24
  8. package/dist/cli.cjs.map +1 -1
  9. package/dist/cli.d.cts +10 -1
  10. package/dist/cli.d.ts +10 -1
  11. package/dist/cli.js +354 -15
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands/add.cjs +2 -2
  14. package/dist/commands/add.cjs.map +1 -1
  15. package/dist/commands/add.js +2 -2
  16. package/dist/commands/add.js.map +1 -1
  17. package/dist/commands/build.cjs +2 -2
  18. package/dist/commands/build.cjs.map +1 -1
  19. package/dist/commands/build.js +2 -2
  20. package/dist/commands/build.js.map +1 -1
  21. package/dist/commands/check.cjs +382 -0
  22. package/dist/commands/check.cjs.map +1 -0
  23. package/dist/commands/check.d.cts +34 -0
  24. package/dist/commands/check.d.ts +34 -0
  25. package/dist/commands/check.js +343 -0
  26. package/dist/commands/check.js.map +1 -0
  27. package/dist/commands/dev.cjs +2 -2
  28. package/dist/commands/dev.cjs.map +1 -1
  29. package/dist/commands/dev.js +2 -2
  30. package/dist/commands/dev.js.map +1 -1
  31. package/dist/commands/dsh.cjs +4 -4
  32. package/dist/commands/dsh.cjs.map +1 -1
  33. package/dist/commands/dsh.js +4 -4
  34. package/dist/commands/dsh.js.map +1 -1
  35. package/dist/commands/generate.cjs +2 -2
  36. package/dist/commands/generate.cjs.map +1 -1
  37. package/dist/commands/generate.js +2 -2
  38. package/dist/commands/generate.js.map +1 -1
  39. package/dist/commands/index.cjs +36 -19
  40. package/dist/commands/index.cjs.map +1 -1
  41. package/dist/commands/index.js +27 -10
  42. package/dist/commands/index.js.map +1 -1
  43. package/dist/commands/init.cjs +20 -3
  44. package/dist/commands/init.cjs.map +1 -1
  45. package/dist/commands/init.js +20 -3
  46. package/dist/commands/init.js.map +1 -1
  47. package/dist/index.cjs +402 -28
  48. package/dist/index.cjs.map +1 -1
  49. package/dist/index.js +391 -17
  50. package/dist/index.js.map +1 -1
  51. package/dist/repl.cjs +34 -17
  52. package/dist/repl.cjs.map +1 -1
  53. package/dist/repl.js +25 -8
  54. package/dist/repl.js.map +1 -1
  55. package/dist/start.cjs +402 -28
  56. package/dist/start.cjs.map +1 -1
  57. package/dist/start.d.cts +8 -1
  58. package/dist/start.d.ts +8 -1
  59. package/dist/start.js +391 -17
  60. package/dist/start.js.map +1 -1
  61. package/dist/utils/logger.cjs +2 -2
  62. package/dist/utils/logger.cjs.map +1 -1
  63. package/dist/utils/logger.js +2 -2
  64. package/dist/utils/logger.js.map +1 -1
  65. package/dist/version.cjs +47 -0
  66. package/dist/version.cjs.map +1 -0
  67. package/dist/version.d.cts +3 -0
  68. package/dist/version.d.ts +3 -0
  69. package/dist/version.js +23 -0
  70. package/dist/version.js.map +1 -0
  71. package/package.json +3 -2
  72. package/src/banner.ts +11 -5
  73. package/src/cli-process.test.ts +139 -0
  74. package/src/cli.ts +31 -3
  75. package/src/commands/check.test.ts +238 -0
  76. package/src/commands/check.ts +461 -0
  77. package/src/commands/dsh.ts +4 -2
  78. package/src/commands/init.ts +2 -1
  79. package/src/start.ts +66 -5
  80. package/src/utils/logger.ts +14 -2
  81. package/src/version.ts +27 -0
@@ -0,0 +1,461 @@
1
+ /**
2
+ * `vobs check` —— 不跑应用就能给出的源码问题清单。
3
+ *
4
+ * 为什么需要它:vobs 里最容易犯的错是**不发声**的错(effect 自订阅、列表写进三元分支
5
+ * 后失去 keyed 复用)。运行时护栏(`@vobs/vobs/dev`)能抓到,但那要求先把应用跑起来;
6
+ * 静态检查让你(和 AI)在**写完之后、运行之前**就知道哪里不对。
7
+ *
8
+ * 产出与框架其余部分同一套词汇(code / severity / fix),字段直接对应
9
+ * `@vobs/runtime/error` 的 `VobsError`,因此能直接喂给开发台面板或 AI。
10
+ *
11
+ * 规则的高精度是刻意的:宁可少报,也不要误报 —— 误报会让 AI 去改本来正确的代码。
12
+ *
13
+ * ## 已知限制
14
+ *
15
+ * 三条规则都是**启发式**的(靠名字与形状判断),所以会撞到合法代码:
16
+ *
17
+ * - `VOBS_C210`:把「同一个 `X.value` 的读写」当作信号自订阅。**分不清信号与恰好叫 `value`
18
+ * 的普通字段** —— `entry.value = x`(DTO / ref / 配置对象)会被误报。收窄成「只认裸标识符」
19
+ * 会连 `props.name.value` 这种真信号一起放过,所以保持现状 + 提供行内抑制。
20
+ * - `VOBS_C232` / `VOBS_C118`:前者看的是「列表表达式是否写在分支里」,后者看「组件体里
21
+ * 是否把信号读取存进了局部变量」。都只看形状,不做类型推断。
22
+ *
23
+ * 撞上误报时的正规做法是**行内抑制**(`// vobs-check-ignore-next-line`),
24
+ * 而不是关掉整条规则或改写本来正确的代码。
25
+ */
26
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
27
+ import path from 'node:path'
28
+ import ts from 'typescript'
29
+ import { logger } from '../utils/logger.js'
30
+
31
+ export interface CheckOptions {
32
+ readonly dir?: string
33
+ readonly json?: boolean
34
+ /** 把结果写到 `<root>/.vobs/check.json`(开发台面板读这个文件显示「项目」页)。 */
35
+ readonly write?: boolean
36
+ /** 把测试文件也纳入检查(默认跳过:fixture 里常有意为之的写法会淹没真问题)。 */
37
+ readonly includeTests?: boolean
38
+ }
39
+
40
+ /** 检查报告的固定落点,相对被检查的根目录。 */
41
+ export const REPORT_PATH = '.vobs/check.json'
42
+
43
+ /** 测试文件默认跳过。 */
44
+ const TEST_FILE = /(?:^|\/)(?:[^/]*\.(?:test|spec)\.tsx?|__tests__\/)/u
45
+
46
+ export interface CheckDiagnostic {
47
+ /** 稳定错误码,AI 与文档按它检索。 */
48
+ readonly code: string
49
+ readonly severity: 'error' | 'warning'
50
+ readonly message: string
51
+ /** 该怎么改。只报错对 AI 没有价值。 */
52
+ readonly fix: string
53
+ /** 相对目标目录的路径。 */
54
+ readonly file: string
55
+ readonly line: number
56
+ readonly column: number
57
+ /** 出错那一行的原文,方便直接定位。 */
58
+ readonly snippet: string
59
+ }
60
+
61
+ /** effect 写入了自己依赖的信号。 */
62
+ export const VOBS_C210 = 'VOBS_C210'
63
+ /** 列表写在分支位置 —— 失去 keyed 复用。 */
64
+ export const VOBS_C232 = 'VOBS_C232'
65
+ /** 在组件体里读信号并存进局部变量 —— 组件体只执行一次,之后永不更新。 */
66
+ export const VOBS_C118 = 'VOBS_C118'
67
+
68
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'lib', 'build', '.git', '.vobs', 'coverage'])
69
+
70
+ /**
71
+ * 收集目标下的 .ts/.tsx。
72
+ *
73
+ * **根目录**读不到时**必须抛错**,不能返回空数组:此前那个 `catch { return }` 把
74
+ * 不存在的目录、以及"误传一个文件进来"都变成 `0 个文件` + exit 0 ——
75
+ * `vobs check <不存在的目录>` 会打印 `✔ 检查通过 —— 0 个文件,没有发现问题`。
76
+ * CI 里把路径写错就是**永久绿灯**,比报错更危险(一个检查工具在最该失败的场景下静默通过)。
77
+ *
78
+ * 子目录读不到仍然跳过(权限不足/竞态删除不该让整次检查失败)——那是有意的容错,
79
+ * 与"根目录不存在"不是一回事。
80
+ */
81
+ async function collectSources(root: string): Promise<string[]> {
82
+ let rootStat
83
+ try {
84
+ rootStat = await stat(root)
85
+ } catch {
86
+ throw new Error(`路径不存在:${root}`)
87
+ }
88
+ if (!rootStat.isDirectory()) {
89
+ throw new Error(`不是目录:${root}(vobs check 接受一个目录)`)
90
+ }
91
+
92
+ const found: string[] = []
93
+ const walk = async (dir: string): Promise<void> => {
94
+ let entries
95
+ try {
96
+ entries = await readdir(dir, { withFileTypes: true })
97
+ } catch (error) {
98
+ // 根目录在第一次 stat 之后消失(竞态)也必须暴露,而不是混进"0 个文件"
99
+ if (dir === root) {
100
+ throw new Error(`无法读取目录:${root}(${error instanceof Error ? error.message : String(error)})`)
101
+ }
102
+ return
103
+ }
104
+ for (const entry of entries) {
105
+ if (entry.isDirectory()) {
106
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue
107
+ await walk(path.join(dir, entry.name))
108
+ continue
109
+ }
110
+ if (!entry.isFile()) continue
111
+ if (!/\.tsx?$/u.test(entry.name) || entry.name.endsWith('.d.ts')) continue
112
+ found.push(path.join(dir, entry.name))
113
+ }
114
+ }
115
+ await walk(root)
116
+ return found.sort()
117
+ }
118
+
119
+ /* ------------------------------------------------------------------ 工具 */
120
+
121
+ /** 该节点是否返回 JSX(函数体里出现 JSX 元素/片段)。 */
122
+ function returnsJsx(node: ts.Node): boolean {
123
+ let seen = false
124
+ const visit = (child: ts.Node): void => {
125
+ if (seen) return
126
+ if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child)) {
127
+ seen = true
128
+ return
129
+ }
130
+ // 不进入嵌套函数:内层组件返回 JSX 不代表外层是组件
131
+ if (child !== node && (ts.isFunctionLike(child))) return
132
+ ts.forEachChild(child, visit)
133
+ }
134
+ ts.forEachChild(node, visit)
135
+ return seen
136
+ }
137
+
138
+ /**
139
+ * `X.value` 形式的内存读取,返回接收者的文本(`count`、`props.name` 都算)。
140
+ *
141
+ * 用文本而不是标识符:信号经常是别人传进来的(`props.name.value`),
142
+ * 只认裸标识符会把这些全漏掉。
143
+ */
144
+ function signalNameOf(node: ts.Node): string | undefined {
145
+ if (!ts.isPropertyAccessExpression(node)) return undefined
146
+ if (node.name.text !== 'value') return undefined
147
+ return node.expression.getText()
148
+ }
149
+
150
+ function lineSnippet(source: ts.SourceFile, node: ts.Node): string {
151
+ const { line } = source.getLineAndCharacterOfPosition(node.getStart(source))
152
+ return (source.text.split(/\r?\n/u)[line] ?? '').trim()
153
+ }
154
+
155
+ function diagnosticAt(
156
+ source: ts.SourceFile,
157
+ file: string,
158
+ node: ts.Node,
159
+ rest: Omit<CheckDiagnostic, 'file' | 'line' | 'column' | 'snippet'>
160
+ ): CheckDiagnostic {
161
+ const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source))
162
+ return {
163
+ ...rest,
164
+ file,
165
+ line: line + 1,
166
+ column: character + 1,
167
+ snippet: lineSnippet(source, node)
168
+ }
169
+ }
170
+
171
+ const isWriteOperator = (kind: ts.SyntaxKind): boolean =>
172
+ kind === ts.SyntaxKind.EqualsToken
173
+ || kind === ts.SyntaxKind.PlusEqualsToken
174
+ || kind === ts.SyntaxKind.MinusEqualsToken
175
+ || kind === ts.SyntaxKind.AsteriskEqualsToken
176
+ || kind === ts.SyntaxKind.SlashEqualsToken
177
+
178
+ /* ------------------------------------------------- 规则 A:effect 自订阅 */
179
+
180
+ function ruleEffectSelfSubscription(source: ts.SourceFile, file: string): CheckDiagnostic[] {
181
+ const found: CheckDiagnostic[] = []
182
+
183
+ const inspectEffectBody = (body: ts.Node): void => {
184
+ const reads = new Map<string, ts.Node>()
185
+ const writes = new Map<string, ts.Node>()
186
+
187
+ const visit = (node: ts.Node, insideUntrack: boolean): void => {
188
+ // 嵌套函数有自己的订阅语义,不算在本次 effect 的读写里
189
+ if (node !== body && ts.isFunctionLike(node)) return
190
+
191
+ // untrack(...) 里的写入不建立订阅 → 正是正确写法
192
+ const nextUntracked = insideUntrack
193
+ || (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'untrack')
194
+
195
+ if (ts.isBinaryExpression(node) && isWriteOperator(node.operatorToken.kind)) {
196
+ const name = signalNameOf(node.left)
197
+ if (name !== undefined && !nextUntracked) writes.set(name, node)
198
+ } else if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node))
199
+ && (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken)) {
200
+ // `count.value++` 同时是读和写 —— 这正是自订阅的经典形态
201
+ const name = signalNameOf(node.operand)
202
+ if (name !== undefined) {
203
+ if (!nextUntracked) writes.set(name, node)
204
+ reads.set(name, node)
205
+ }
206
+ } else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
207
+ && node.expression.name.text === 'set') {
208
+ if (!nextUntracked) writes.set(node.expression.expression.getText(), node)
209
+ } else {
210
+ const name = signalNameOf(node)
211
+ if (name !== undefined) {
212
+ const parent = node.parent
213
+ // 只有赋值类运算符的左侧才不是「读」;`count.value < 1` 的左侧仍然是读
214
+ const isAssignmentTarget = ts.isBinaryExpression(parent)
215
+ && isWriteOperator(parent.operatorToken.kind)
216
+ && parent.left === node
217
+ if (!isAssignmentTarget) reads.set(name, node)
218
+ }
219
+ }
220
+
221
+ ts.forEachChild(node, child => visit(child, nextUntracked))
222
+ }
223
+
224
+ visit(body, false)
225
+
226
+ for (const [name, writeNode] of writes) {
227
+ if (!reads.has(name)) continue
228
+ found.push(diagnosticAt(source, file, writeNode, {
229
+ code: VOBS_C210,
230
+ severity: 'error',
231
+ message: `effect 写入了它自己依赖的信号 "${name}" —— 这次写入会把它重新调度,形成自订阅循环`,
232
+ fix: `把这次写入包进 untrack:untrack(() => { ${name}.value = next });`
233
+ + '如果这个 effect 本来就只该做副作用,检查是不是误读了不该读的信号。'
234
+ }))
235
+ }
236
+ }
237
+
238
+ const visit = (node: ts.Node): void => {
239
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)
240
+ && (node.expression.text === 'effect' || node.expression.text === 'renderEffect')) {
241
+ const callback = node.arguments[0]
242
+ if (callback !== undefined && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) {
243
+ inspectEffectBody(callback.body)
244
+ }
245
+ }
246
+ ts.forEachChild(node, visit)
247
+ }
248
+ visit(source)
249
+
250
+ return found
251
+ }
252
+
253
+ /* -------------------------------------- 规则 B:列表写在分支位置(C232) */
254
+
255
+ function isKeyedListCall(node: ts.Node): boolean {
256
+ if (!ts.isCallExpression(node)) return false
257
+ if (!ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== 'map') return false
258
+ const callback = node.arguments[0]
259
+ return callback !== undefined && ts.isFunctionLike(callback) && returnsJsx(callback)
260
+ }
261
+
262
+ function ruleListInBranch(source: ts.SourceFile, file: string): CheckDiagnostic[] {
263
+ const found: CheckDiagnostic[] = []
264
+ const message = 'list 写在三元/&& 的分支里 —— 会走多态插入,失去 keyed 复用'
265
+ const fix = '把 list 提成**直接的**子表达式:先写条件分支,再单独写 {items.map(...)}。'
266
+ + '三元里同时有节点与 list 时,list 那一支不会编译成 insertList。'
267
+
268
+ const visit = (node: ts.Node): void => {
269
+ if (ts.isConditionalExpression(node)) {
270
+ for (const branch of [node.whenTrue, node.whenFalse]) {
271
+ if (isKeyedListCall(branch)) {
272
+ found.push(diagnosticAt(source, file, branch, { code: VOBS_C232, severity: 'warning', message, fix }))
273
+ }
274
+ }
275
+ } else if (ts.isBinaryExpression(node)
276
+ && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
277
+ && isKeyedListCall(node.right)) {
278
+ found.push(diagnosticAt(source, file, node.right, { code: VOBS_C232, severity: 'warning', message, fix }))
279
+ }
280
+ ts.forEachChild(node, visit)
281
+ }
282
+ visit(source)
283
+
284
+ return found
285
+ }
286
+
287
+ /* --------------------------------- 规则 C:组件体里读信号存局部变量(C118) */
288
+
289
+ function containsSignalRead(node: ts.Node): boolean {
290
+ let seen = false
291
+ const visit = (child: ts.Node): void => {
292
+ if (seen) return
293
+ // 函数体内的读取是**延迟**的(事件处理器、定时器、回调),不算「组件体里读信号」。
294
+ // 这条必须放在最前面:`const handler = () => { signal.value }` 里的读取完全正常。
295
+ if (ts.isFunctionLike(child)) return
296
+ if (signalNameOf(child) !== undefined) {
297
+ seen = true
298
+ return
299
+ }
300
+ ts.forEachChild(child, visit)
301
+ }
302
+ visit(node)
303
+ return seen
304
+ }
305
+
306
+ function ruleSignalCapturedInBody(source: ts.SourceFile, file: string): CheckDiagnostic[] {
307
+ const found: CheckDiagnostic[] = []
308
+
309
+ const inspectComponent = (fn: ts.FunctionLikeDeclaration): void => {
310
+ const body = fn.body
311
+ if (body === undefined || !ts.isBlock(body) || !returnsJsx(fn)) return
312
+
313
+ // 组件体里「读信号 + 存进 const」
314
+ const captured: { name: string; node: ts.VariableDeclaration }[] = []
315
+ for (const statement of body.statements) {
316
+ if (!ts.isVariableStatement(statement)) continue
317
+ for (const declaration of statement.declarationList.declarations) {
318
+ if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue
319
+ if (containsSignalRead(declaration.initializer)) {
320
+ captured.push({ name: declaration.name.text, node: declaration })
321
+ }
322
+ }
323
+ }
324
+ if (captured.length === 0) return
325
+
326
+ // 这些名字是否在 JSX 表达式里被用到
327
+ const usedInJsx = new Set<string>()
328
+ const visit = (node: ts.Node): void => {
329
+ if (ts.isJsxExpression(node) && node.expression !== undefined) {
330
+ const scan = (child: ts.Node): void => {
331
+ if (ts.isIdentifier(child)) usedInJsx.add(child.text)
332
+ ts.forEachChild(child, scan)
333
+ }
334
+ scan(node.expression)
335
+ }
336
+ ts.forEachChild(node, visit)
337
+ }
338
+ visit(body)
339
+
340
+ for (const item of captured) {
341
+ if (!usedInJsx.has(item.name)) continue
342
+ found.push(diagnosticAt(source, file, item.node, {
343
+ code: VOBS_C118,
344
+ severity: 'warning',
345
+ message: `组件体里读了信号并存进 "${item.name}" —— 组件体只执行一次,这个值之后永远不会更新`,
346
+ fix: `把读取放进动态表达式(直接在 JSX 里用 .value),或改成 memo 派生值:`
347
+ + `const ${item.name} = memo(() => ...)。`
348
+ }))
349
+ }
350
+ }
351
+
352
+ const visit = (node: ts.Node): void => {
353
+ if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) {
354
+ inspectComponent(node)
355
+ }
356
+ ts.forEachChild(node, visit)
357
+ }
358
+ visit(source)
359
+
360
+ return found
361
+ }
362
+
363
+ /* -------------------------------------------------------------- 入口 */
364
+
365
+ /**
366
+ * 行内抑制:`// vobs-check-ignore-next-line`。
367
+ *
368
+ * 这三条规则都是**启发式**的:它们靠名字与形状判断「这看起来像信号自订阅 / 像写在分支里的列表 /
369
+ * 像在组件体里读信号」,而有些合法代码恰好长成那样。检查器没有逃生口的话,用户只能关掉整条
370
+ * 规则(或者被 CI 挡住去做假的改写)—— 那比漏报更糟。
371
+ *
372
+ * 只支持「抑制下一行」:作用范围最小、读代码时一眼看得见,也没有整文件豁免那种一刀切。
373
+ * 实测就撞到过一例:`ListEntry.value` 是普通字段,但写法与信号自订阅完全相同
374
+ * (后来给它改了名,但用户代码里不会有这种运气)。
375
+ */
376
+ const IGNORE_NEXT_LINE = /vobs-check-ignore-next-line/u
377
+
378
+ /** 被行内注释抑制的行号集合(1-based,收集的是「注释的下一行」)。 */
379
+ function suppressedLines(text: string): Set<number> {
380
+ const suppressed = new Set<number>()
381
+ const lines = text.split(/\r?\n/u)
382
+ for (let index = 0; index < lines.length; index++) {
383
+ if (IGNORE_NEXT_LINE.test(lines[index])) suppressed.add(index + 2)
384
+ }
385
+ return suppressed
386
+ }
387
+
388
+ export function analyzeSource(text: string, file: string): CheckDiagnostic[] {
389
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
390
+ const suppressed = suppressedLines(text)
391
+ return [
392
+ ...ruleEffectSelfSubscription(source, file),
393
+ ...ruleListInBranch(source, file),
394
+ ...ruleSignalCapturedInBody(source, file)
395
+ ]
396
+ .filter(item => !suppressed.has(item.line))
397
+ .sort((a, b) => a.line - b.line || a.column - b.column)
398
+ }
399
+
400
+ export async function checkCommand(options: CheckOptions = {}): Promise<void> {
401
+ const root = path.resolve(options.dir ?? process.cwd())
402
+ let all: string[]
403
+ try {
404
+ all = await collectSources(root)
405
+ } catch (error) {
406
+ // 路径无效必须是**失败**,不能是"0 个文件,检查通过"(见 collectSources 注释)
407
+ logger.error(error instanceof Error ? error.message : String(error))
408
+ process.exitCode = 1
409
+ return
410
+ }
411
+ const files = options.includeTests === true
412
+ ? all
413
+ : all.filter(file => !TEST_FILE.test(path.relative(root, file).split(path.sep).join('/')))
414
+ const skipped = all.length - files.length
415
+
416
+ const diagnostics: CheckDiagnostic[] = []
417
+ for (const absolute of files) {
418
+ const text = await readFile(absolute, 'utf8')
419
+ const relative = path.relative(root, absolute).split(path.sep).join('/')
420
+ diagnostics.push(...analyzeSource(text, relative))
421
+ }
422
+
423
+ const errors = diagnostics.filter(item => item.severity === 'error')
424
+ const report = { root, files: files.length, skippedTests: skipped, diagnostics }
425
+
426
+ if (options.write === true) {
427
+ const target = path.join(root, REPORT_PATH)
428
+ await mkdir(path.dirname(target), { recursive: true })
429
+ await writeFile(target, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
430
+ // --json 时 stdout 必须只剩 JSON,所以这行提示走 logger(stderr)
431
+ logger.info(`报告写入 ${REPORT_PATH}(开发台面板读它显示「项目」页)`)
432
+ }
433
+
434
+ if (options.json === true) {
435
+ console.log(JSON.stringify(report, null, 2))
436
+ if (errors.length > 0) process.exitCode = 1
437
+ return
438
+ }
439
+
440
+ if (diagnostics.length === 0) {
441
+ logger.success(`检查通过 —— ${files.length} 个文件${skipped > 0 ? `(跳过 ${skipped} 个测试文件)` : ''},没有发现问题`)
442
+ return
443
+ }
444
+
445
+ console.log('')
446
+ for (const item of diagnostics) {
447
+ const mark = item.severity === 'error' ? '✖' : '!'
448
+ console.log(` ${mark} ${item.file}:${item.line}:${item.column} ${item.code}`)
449
+ console.log(` ${item.message}`)
450
+ if (item.snippet !== '') console.log(` ${item.snippet}`)
451
+ console.log(` → ${item.fix}`)
452
+ console.log('')
453
+ }
454
+ const warnings = diagnostics.length - errors.length
455
+ console.log(
456
+ ` ${errors.length} 个错误 · ${warnings} 个警告 · 共检查 ${files.length} 个文件`
457
+ + `${skipped > 0 ? `(跳过 ${skipped} 个测试文件,用 --include-tests 纳入)` : ''}\n`
458
+ )
459
+
460
+ if (errors.length > 0) process.exitCode = 1
461
+ }
@@ -663,11 +663,13 @@ export async function dshCommand(
663
663
  dshInstallCommand(options)
664
664
  return
665
665
  default:
666
- logger.error(`未知子命令: ${action ?? '(空)'}`)
666
+ // 不带子命令时只列用法(这是最自然的「我该用什么」入口),
667
+ // 真正写错子命令时才报错。
668
+ if (action !== undefined && action !== '') logger.error(`未知子命令: ${action}`)
667
669
  console.log('\n 可用子命令:')
668
670
  for (const [name, description] of Object.entries(ACTIONS)) {
669
671
  console.log(` vobs dsh ${name.padEnd(8)} ${description}`)
670
672
  }
671
- process.exitCode = 1
673
+ if (action !== undefined && action !== '') process.exitCode = 1
672
674
  }
673
675
  }
@@ -5,6 +5,7 @@ import { logger } from '../utils/logger.js'
5
5
  import { scaffoldProject } from '../utils/file.js'
6
6
  import { installDependencies } from '../utils/pm.js'
7
7
  import type { InitOptions } from '../types.js'
8
+ import { cliVersion } from '../version.js'
8
9
 
9
10
  export async function initCommand(options: Partial<InitOptions>): Promise<void> {
10
11
  const projectName = options.name || (await p.text({
@@ -60,7 +61,7 @@ export async function initCommand(options: Partial<InitOptions>): Promise<void>
60
61
  name: projectName,
61
62
  packageName: projectName.toLowerCase().replace(/\s+/g, '-'),
62
63
  typescript: useTypescript,
63
- vobsVersion: '1.0.0'
64
+ vobsVersion: cliVersion()
64
65
  })
65
66
 
66
67
  logger.success('Project scaffolded')
package/src/start.ts CHANGED
@@ -1,8 +1,28 @@
1
- import { createCLI } from './cli.js'
1
+ import { createCLI, KNOWN_SUBCOMMANDS } from './cli.js'
2
2
  import { showBanner } from './banner.js'
3
3
  import { startRepl } from './repl.js'
4
4
 
5
- /** Entry point: decides between interactive REPL and single-shot commands */
5
+ /** 帮助/版本文本里出现的 flag —— 它们不是"未知 flag"。 */
6
+ const META_FLAGS = new Set(['--help', '-h', '--version', '-V'])
7
+
8
+ /**
9
+ * 该 flag 是否属于**顶层**用法(相对"某个子命令的 flag"而言)。
10
+ *
11
+ * `vobs --port 3000` 里的 `--port` 不是顶层 flag(它属于 `dev`),
12
+ * 所以这里只认 `-`/`--` 开头的未知项,交由调用方按"有没有子命令"决定是不是错误。
13
+ */
14
+ function isFlag(token: string): boolean {
15
+ return token.startsWith('-') && token !== '-'
16
+ }
17
+
18
+ /**
19
+ * 单命令分发。
20
+ *
21
+ * 此前 `start()` 在 `--version` 之后直接 `createCLI().parse()` 就结束,而 cac 对
22
+ * "没有命令匹配"**不做任何兜底**:既不报错、也不输出、退出码还是 0。
23
+ * 于是 `vobs chekc`(拼错)在 CI 里静默成功;`vobs --totally-unknown` 同样 0 字节 + exit 0。
24
+ * 这里补上兜底:未知子命令与未知顶层 flag 都进 stderr 且 **exit 1**。
25
+ */
6
26
  export async function start(args: string[]): Promise<void> {
7
27
  // No args (or explicit `cli`) → launch the interactive REPL
8
28
  if (args.length === 0 || args[0] === 'cli') {
@@ -10,11 +30,52 @@ export async function start(args: string[]): Promise<void> {
10
30
  return
11
31
  }
12
32
 
13
- if (args.includes('--version') || args.includes('-V')) {
33
+ const first = args[0]
34
+ const hasSubcommand = KNOWN_SUBCOMMANDS.includes(first)
35
+
36
+ /*
37
+ * `--version` / `-V` 只有在**没有子命令**时才表示"打印版本"。
38
+ * 此前是 `args.includes('--version')` —— 任何位置出现都会短路,
39
+ * 于是 `vobs check --version` 只打 banner、check 根本没跑(静默吞掉一个真命令)。
40
+ */
41
+ if (!hasSubcommand && (args.includes('--version') || args.includes('-V'))) {
14
42
  showBanner()
15
43
  return
16
44
  }
17
45
 
46
+ if (!hasSubcommand && !isFlag(first)) {
47
+ process.stderr.write(`✖ 未知命令:${first}\n\n`)
48
+ try {
49
+ createCLI().parse(['node', 'vobs', '--help'])
50
+ } catch {
51
+ // 帮助输出失败不应掩盖真正的错误:错误已写进 stderr、退出码才是判据
52
+ }
53
+ process.exitCode = 1
54
+ return
55
+ }
56
+
57
+ if (!hasSubcommand && isFlag(first) && !META_FLAGS.has(first)) {
58
+ process.stderr.write(`✖ 未知选项:${first}\n\n`)
59
+ try {
60
+ createCLI().parse(['node', 'vobs', '--help'])
61
+ } catch {
62
+ // 同上
63
+ }
64
+ process.exitCode = 1
65
+ return
66
+ }
67
+
18
68
  // Let cac read the full process.argv so single-shot commands dispatch correctly
19
- createCLI().parse()
20
- }
69
+ try {
70
+ createCLI().parse()
71
+ } catch (error) {
72
+ /*
73
+ * cac 对**已匹配子命令**的未知选项/缺值会抛 `CACError`。此前它一路冒到
74
+ * `bin/vobs.js` 的顶层 `await start(...)`(无 try/catch),用户看到的是
75
+ * 半屏 Node 栈而不是一句可读的错误。
76
+ */
77
+ const message = error instanceof Error ? error.message : String(error)
78
+ process.stderr.write(`✖ ${message}\n`)
79
+ process.exitCode = 1
80
+ }
81
+ }
@@ -1,11 +1,23 @@
1
1
  import pc from 'picocolors'
2
2
 
3
+ /*
4
+ * 诊断/日志一律走 **stderr**,stdout 只留给**数据**。
5
+ *
6
+ * 为什么这不是洁癖:`vobs check --json` 的输出是给 AI 与开发台面板消费的机器接口,
7
+ * 而 `--json --write` 时 `logger.info('报告写入 …')` 会先往 stdout 打一行带时间戳的
8
+ * `[14:24:23] ℹ …`,于是 `JSON.parse(stdout)` 直接失败(实测
9
+ * `.artifacts/probe-audit-cli-check.mjs`:同一脚本里 `--json` 可 parse、`--json --write` 不可)。
10
+ * `warn` 同理。
11
+ *
12
+ * 注意:`error` 本来就走 stderr;`success`/`step` 保持 stdout(它们是给人看的**主输出**,
13
+ * 不是旁路日志),`check` 的成功行也仍是 stdout。
14
+ */
3
15
  function timestamp(): string {
4
16
  return pc.dim(`[${new Date().toLocaleTimeString()}]`)
5
17
  }
6
18
 
7
19
  export function info(msg: string): void {
8
- console.log(`${timestamp()} ${pc.cyan('ℹ')} ${msg}`)
20
+ console.error(`${timestamp()} ${pc.cyan('ℹ')} ${msg}`)
9
21
  }
10
22
 
11
23
  export function success(msg: string): void {
@@ -13,7 +25,7 @@ export function success(msg: string): void {
13
25
  }
14
26
 
15
27
  export function warn(msg: string): void {
16
- console.log(`${timestamp()} ${pc.yellow('⚠')} ${msg}`)
28
+ console.error(`${timestamp()} ${pc.yellow('⚠')} ${msg}`)
17
29
  }
18
30
 
19
31
  export function error(msg: string): void {
package/src/version.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ /**
4
+ * CLI 版本号的**单一来源**:直接读本包的 `package.json`。
5
+ *
6
+ * 此前版本号在三处各自硬编码,且都与 `package.json` 不符:
7
+ * - `banner.ts` 的 `𝗩𝗢𝗕𝗦 𝗖𝗟𝗜 𝘃𝟭.𝟬` 常量 —— 这就是 `vobs --version` 的**全部**输出;
8
+ * - `cli.ts` 的 `cli.version('1.0.0')` —— `--help` 头部显示 `vobs/1.0.0`;
9
+ * - `init.ts` 的 `vobsVersion: '1.0.0'` —— 写进脚手架产物。
10
+ * 同一个仓库的 `commands/dsh.ts` 早就有正确的"读 package.json"实现,所以这不是能力问题,
11
+ * 而是没人从**外部**看过 CLI 的版本输出(三处不一致也正因如此从未被发现)。
12
+ */
13
+ let cached: string | undefined
14
+
15
+ export function cliVersion(): string {
16
+ if (cached !== undefined) return cached
17
+ try {
18
+ const manifest = JSON.parse(
19
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8')
20
+ ) as { version?: string }
21
+ cached = manifest.version ?? '0.0.0'
22
+ } catch {
23
+ // 读不到 package.json(被打包成单文件等)时不要谎报一个像真版本号的常量
24
+ cached = '0.0.0'
25
+ }
26
+ return cached
27
+ }