@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,23 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // packages/cli/src/version.ts
5
+ import { readFileSync } from "fs";
6
+ var cached;
7
+ function cliVersion() {
8
+ if (cached !== void 0) return cached;
9
+ try {
10
+ const manifest = JSON.parse(
11
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
12
+ );
13
+ cached = manifest.version ?? "0.0.0";
14
+ } catch {
15
+ cached = "0.0.0";
16
+ }
17
+ return cached;
18
+ }
19
+ __name(cliVersion, "cliVersion");
20
+ export {
21
+ cliVersion
22
+ };
23
+ //# sourceMappingURL=version.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/version.ts"],"sourcesContent":["import { readFileSync } from 'node:fs'\n\n/**\n * CLI 版本号的**单一来源**:直接读本包的 `package.json`。\n *\n * 此前版本号在三处各自硬编码,且都与 `package.json` 不符:\n * - `banner.ts` 的 `𝗩𝗢𝗕𝗦 𝗖𝗟𝗜 𝘃𝟭.𝟬` 常量 —— 这就是 `vobs --version` 的**全部**输出;\n * - `cli.ts` 的 `cli.version('1.0.0')` —— `--help` 头部显示 `vobs/1.0.0`;\n * - `init.ts` 的 `vobsVersion: '1.0.0'` —— 写进脚手架产物。\n * 同一个仓库的 `commands/dsh.ts` 早就有正确的\"读 package.json\"实现,所以这不是能力问题,\n * 而是没人从**外部**看过 CLI 的版本输出(三处不一致也正因如此从未被发现)。\n */\nlet cached: string | undefined\n\nexport function cliVersion(): string {\n if (cached !== undefined) return cached\n try {\n const manifest = JSON.parse(\n readFileSync(new URL('../package.json', import.meta.url), 'utf8')\n ) as { version?: string }\n cached = manifest.version ?? '0.0.0'\n } catch {\n // 读不到 package.json(被打包成单文件等)时不要谎报一个像真版本号的常量\n cached = '0.0.0'\n }\n return cached\n}\n"],"mappings":";;;;AAAA,SAAS,oBAAoB;AAY7B,IAAI;AAEG,SAAS,aAAqB;AACnC,MAAI,WAAW,OAAW,QAAO;AACjC,MAAI;AACF,UAAM,WAAW,KAAK;AAAA,MACpB,aAAa,IAAI,IAAI,mBAAmB,YAAY,GAAG,GAAG,MAAM;AAAA,IAClE;AACA,aAAS,SAAS,WAAW;AAAA,EAC/B,QAAQ;AAEN,aAAS;AAAA,EACX;AACA,SAAO;AACT;AAZgB;","names":[]}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "type": "git",
5
5
  "url": "git+https://github.com/vobsjs/vobs.git"
6
6
  },
7
- "version": "1.7.7",
7
+ "version": "1.8.0",
8
8
  "publishConfig": {
9
9
  "access": "public"
10
10
  },
@@ -35,7 +35,8 @@
35
35
  "cac": "^6.7.14",
36
36
  "handlebars": "^4.7.8",
37
37
  "picocolors": "^1.0.0",
38
- "tsx": "^4.19.0"
38
+ "tsx": "^4.19.0",
39
+ "typescript": "^5.9.3"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
package/src/banner.ts CHANGED
@@ -1,15 +1,21 @@
1
1
  /**
2
2
  * VOBS CLI Banner — Mathematical Bold Sans-Serif
3
- * 𝗩𝗢𝗕𝗦 𝗖𝗟𝗜 𝘃𝟭.𝟬
3
+ * 𝗩𝗢𝗕𝗦 𝗖𝗟𝗜
4
4
  * Signals First · Zero Re-renders · Ultra Lightweight
5
5
  *
6
6
  * The banner is left-aligned with a fixed indent rather than horizontally
7
7
  * centered. Centering depends on the terminal column width and per-glyph
8
8
  * render width, neither of which is reliably knowable, so it drifts as the
9
9
  * window resizes. Fixed indent is stable across any terminal size.
10
+ *
11
+ * ⚠️ 这里**只放品牌字形,不放版本号**。版本号曾以数学粗体常量 `𝘃𝟭.𝟬` 的形式写死在这里,
12
+ * 而它就是 `--version` 的全部输出 —— 打印一个和 package.json 不符的版本比不打印更糟。
13
+ * 真实版本统一由 `version.ts` 的 `cliVersion()` 提供。
10
14
  */
11
15
 
12
- const BANNER_LINE1 = '𝗩𝗢𝗕𝗦 𝗖𝗟𝗜 𝘃𝟭.𝟬'
16
+ import { cliVersion } from './version.js'
17
+
18
+ const BANNER_LINE1 = '𝗩𝗢𝗕𝗦 𝗖𝗟𝗜'
13
19
  const BANNER_TAGLINE = 'Signals First · Zero Re-renders · Ultra Lightweight'
14
20
 
15
21
  /** Fixed left indent applied to every banner line (2 spaces). */
@@ -28,14 +34,14 @@ export function showLogo(): void {
28
34
  console.log()
29
35
  }
30
36
 
31
- /** Show single-line version banner (used for --version) */
37
+ /** Show single-line version banner (used for --version):真实版本号,不是品牌字形。 */
32
38
  export function showBanner(): void {
33
- console.log(`${INDENT}${BANNER_LINE1}`)
39
+ console.log(`${INDENT}${BANNER_LINE1} v${cliVersion()}`)
34
40
  }
35
41
 
36
42
  /** Show the dev server start banner */
37
43
  export function showDevBanner(): void {
38
44
  console.log()
39
- console.log(`${INDENT}${BANNER_LINE1}`)
45
+ console.log(`${INDENT}${BANNER_LINE1} v${cliVersion()}`)
40
46
  console.log()
41
47
  }
@@ -0,0 +1,139 @@
1
+ // @vitest-environment node
2
+
3
+ import { execFile } from 'node:child_process'
4
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import path from 'node:path'
7
+ import { afterAll, describe, expect, it } from 'vitest'
8
+
9
+ /*
10
+ * CLI 的**参数解析 / 退出码 / stdout vs stderr 分流**这一整层此前是零覆盖:
11
+ * `check.test.ts` 与 `dsh.test.ts` 断言的全是纯函数(analyzeSource、collectIssues、
12
+ * buildInstallSpec …),**没有任何一条测试真正 spawn 过 CLI 进程**。
13
+ * 所以下面这些缺陷一个都不可能被测出:
14
+ *
15
+ * - `vobs nosuchcmd` / `vobs --totally-unknown` → exit 0 + 零输出(cac 不做兜底,start() 也不兜)
16
+ * - `vobs check <不存在的目录>` → `✔ 检查通过 —— 0 个文件` + exit 0(readdir 失败被空 catch 吞掉)
17
+ * —— CI 里把路径写错就是**永久绿灯**
18
+ * - `vobs --version` 打印的是**硬编码**常量 `𝗩𝗢𝗕𝗦 𝗖𝗟𝗜 𝘃𝟭.𝟬`,与 package.json 不符
19
+ * - `vobs check --version` 只打 banner、check 根本没跑(`args.includes('--version')` 在分发前短路)
20
+ *
21
+ * 真跑进程是唯一能验这一层的方法(in-process 断言会把 stdout 分流和退出码一起模拟掉)。
22
+ */
23
+ /*
24
+ * 路径从 `process.cwd()` 取(vitest 的 cwd 就是仓库根)—— 本仓库既有的测试
25
+ * (theme/css-variable-contract.test.ts、tailwind/layer-order.test.ts)用的是同一约定。
26
+ * 早期版本用 `new URL('..', import.meta.url)` 推仓库根,实测 `import.meta.url` 在
27
+ * 这里的基址不是该测试文件,推出来是 C:\Users\ck\Desktop —— 静默错了一个层级。
28
+ */
29
+ const repoRoot = process.cwd()
30
+ const binPath = path.join(repoRoot, 'packages', 'cli', 'bin', 'vobs.js')
31
+ const cliVersion = (JSON.parse(
32
+ readFileSync(path.join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8')
33
+ ) as { version: string }).version
34
+
35
+ const tempDir = mkdtempSync(path.join(tmpdir(), 'vobs-cli-proc-'))
36
+ afterAll(() => { rmSync(tempDir, { recursive: true, force: true }) })
37
+
38
+ interface RunResult { code: number; stdout: string; stderr: string }
39
+
40
+ function runCli(args: string[], timeout = 60_000): Promise<RunResult> {
41
+ return new Promise(resolve => {
42
+ execFile(process.execPath, [binPath, ...args], { cwd: tempDir, timeout }, (error, stdout, stderr) => {
43
+ const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0
44
+ resolve({ code, stdout: String(stdout), stderr: String(stderr) })
45
+ })
46
+ })
47
+ }
48
+
49
+ // 每次 spawn 都要起 tsx 的 ESM loader,本机实测 1.4–1.5s,所以给足超时
50
+ const CLI_TIMEOUT = 60_000
51
+
52
+ describe('vobs CLI 的进程级契约', () => {
53
+ it('未知子命令:进 stderr 且 exit 1(此前 exit 0 + 零输出)', async () => {
54
+ const result = await runCli(['nosuchcommand'])
55
+ expect(result.code).toBe(1)
56
+ expect(result.stderr).toContain('未知命令')
57
+ expect(result.stderr).toContain('nosuchcommand')
58
+ }, CLI_TIMEOUT)
59
+
60
+ it('未知顶层选项:进 stderr 且 exit 1', async () => {
61
+ const result = await runCli(['--totally-unknown'])
62
+ expect(result.code).toBe(1)
63
+ expect(result.stderr).toContain('未知选项')
64
+ }, CLI_TIMEOUT)
65
+
66
+ it('--version 打印真实版本号,而不是硬编码常量', async () => {
67
+ const result = await runCli(['--version'])
68
+ expect(result.code).toBe(0)
69
+ expect(result.stdout).toContain(`v${cliVersion}`)
70
+ // 曾经的硬编码:数学粗体 𝟭.𝟬
71
+ expect(result.stdout).not.toContain('𝟭.𝟬')
72
+ }, CLI_TIMEOUT)
73
+
74
+ it('check 遇到不存在的目录:exit 1,绝不打印"检查通过"', async () => {
75
+ const missing = path.join(tempDir, 'definitely-not-here')
76
+ const result = await runCli(['check', missing])
77
+ expect(result.code).toBe(1)
78
+ expect(result.stdout).not.toContain('检查通过')
79
+ expect(result.stderr).toContain('路径不存在')
80
+ }, CLI_TIMEOUT)
81
+
82
+ it('check 传文件而不是目录:exit 1,绝不打印"检查通过"', async () => {
83
+ const file = path.join(tempDir, 'not-a-dir.json')
84
+ writeFileSync(file, '{}\n', 'utf8')
85
+ const result = await runCli(['check', file])
86
+ expect(result.code).toBe(1)
87
+ expect(result.stdout).not.toContain('检查通过')
88
+ expect(result.stderr).toContain('不是目录')
89
+ }, CLI_TIMEOUT)
90
+
91
+ it('check 真的跑起来(对照):干净目录 exit 0 且打印"检查通过"', async () => {
92
+ const clean = path.join(tempDir, 'clean-src')
93
+ mkdirSync(clean, { recursive: true })
94
+ writeFileSync(path.join(clean, 'ok.tsx'), 'export const A = () => <div>ok</div>\n', 'utf8')
95
+ const result = await runCli(['check', clean])
96
+ expect(result.code).toBe(0)
97
+ expect(result.stdout).toContain('检查通过')
98
+ }, CLI_TIMEOUT)
99
+
100
+ it('check --json 的 stdout 是**纯 JSON**(可直接 JSON.parse)', async () => {
101
+ const clean = path.join(tempDir, 'json-src')
102
+ mkdirSync(clean, { recursive: true })
103
+ writeFileSync(path.join(clean, 'ok.tsx'), 'export const A = () => <div>ok</div>\n', 'utf8')
104
+
105
+ const plain = await runCli(['check', clean, '--json'])
106
+ expect(plain.code).toBe(0)
107
+ expect(() => JSON.parse(plain.stdout)).not.toThrow()
108
+
109
+ /*
110
+ * 关键对照:加 `--write` 后 stdout 必须**仍然**是纯 JSON。
111
+ * 此前 `logger.info('报告写入 …')` 往 stdout 打一行带时间戳的
112
+ * `[14:24:23] ℹ …`,于是 `JSON.parse(stdout)` 直接失败 ——
113
+ * 而 `--json` 是给 AI 与开发台面板消费的机器接口,这使它形同虚设。
114
+ * 修法:logger 的 info/warn 走 stderr,stdout 只留数据。
115
+ */
116
+ const withWrite = await runCli(['check', clean, '--json', '--write'])
117
+ expect(withWrite.code).toBe(0)
118
+ expect(() => JSON.parse(withWrite.stdout)).not.toThrow()
119
+ // 提示信息没有消失,只是搬到了 stderr
120
+ expect(withWrite.stderr).toContain('报告写入')
121
+ }, CLI_TIMEOUT)
122
+
123
+ it('check 找到 error 级问题时 exit 1(确保上一条不是"永远通过")', async () => {
124
+ const bad = path.join(tempDir, 'bad-src')
125
+ mkdirSync(bad, { recursive: true })
126
+ // effect 写入了它自己依赖的信号 —— C210(error 级;注意 C118 只是 warning)
127
+ writeFileSync(
128
+ path.join(bad, 'bad.tsx'),
129
+ 'import { effect, state } from "@vobs/vobs"\n'
130
+ + 'const count = state(0)\n'
131
+ + 'export const A = () => { effect(() => { count.value = count.value + 1 }); return <div>x</div> }\n',
132
+ 'utf8'
133
+ )
134
+ const result = await runCli(['check', bad])
135
+ expect(result.code).toBe(1)
136
+ expect(result.stdout).toContain('VOBS_C210')
137
+ expect(result.stdout).toContain('1 个错误')
138
+ }, CLI_TIMEOUT)
139
+ })
package/src/cli.ts CHANGED
@@ -5,7 +5,9 @@ import { buildCommand } from './commands/build.js'
5
5
  import { generateCommand } from './commands/generate.js'
6
6
  import { addCommand } from './commands/add.js'
7
7
  import { dshCommand } from './commands/dsh.js'
8
+ import { checkCommand } from './commands/check.js'
8
9
  import { showBanner, showLogo, showDevBanner } from './banner.js'
10
+ import { cliVersion } from './version.js'
9
11
 
10
12
  export function createCLI(): ReturnType<typeof cac> {
11
13
  const cli = cac('vobs')
@@ -93,7 +95,21 @@ export function createCLI(): ReturnType<typeof cac> {
93
95
  })
94
96
 
95
97
  cli
96
- .command('dsh <action> [target]', 'DSH 插件工具链(init / dev / build / check / install)')
98
+ .command('check [dir]', '静态检查源码:effect 自订阅 / 列表写进分支 / 组件体里读信号(行内抑制:上一行写 // vobs-check-ignore-next-line)')
99
+ .option('--json', '以 JSON 输出(给 AI 与工具消费)')
100
+ .option('--write', `把结果写到 ${'.vobs/check.json'}(开发台面板读它)`)
101
+ .option('--include-tests', '把测试文件也纳入检查(默认跳过)')
102
+ .action(async (dir: string | undefined, options: Record<string, unknown>) => {
103
+ await checkCommand({
104
+ dir,
105
+ json: options.json === true,
106
+ write: options.write === true,
107
+ includeTests: options.includeTests === true
108
+ })
109
+ })
110
+
111
+ cli
112
+ .command('dsh [action] [target]', 'DSH 插件工具链(init / dev / build / check / install)')
97
113
  .option('--dir <dir>', 'init:目标目录')
98
114
  .option('--pm <pm>', 'init:包管理器(pnpm/npm)')
99
115
  .option('--port <port>', 'dev:预览端口', { default: 5199 })
@@ -130,7 +146,19 @@ export function createCLI(): ReturnType<typeof cac> {
130
146
  })
131
147
 
132
148
  cli.help()
133
- cli.version('1.0.0')
149
+ cli.version(cliVersion())
134
150
 
135
151
  return cli
136
- }
152
+ }
153
+
154
+ /**
155
+ * 已注册的子命令名(含别名)。
156
+ *
157
+ * 存在的理由:cac 对"没有命令匹配"不做任何兜底(既不报错也不输出,直接返回),
158
+ * 而 `start()` 的 `--version` 短路又用的是 `args.includes('--version')` ——
159
+ * 于是 `vobs chekc`(拼错)静默成功,`vobs check --version` 则只打 banner、check 根本没跑。
160
+ * 用这份清单把"跑某个命令"与"顶层用法错误"分开。
161
+ */
162
+ export const KNOWN_SUBCOMMANDS: readonly string[] = [
163
+ 'init', 'create', 'dev', 'build', 'generate', 'g', 'add', 'check', 'dsh', 'logo'
164
+ ]
@@ -0,0 +1,238 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { analyzeSource, VOBS_C118, VOBS_C210, VOBS_C232, type CheckDiagnostic } from './check'
3
+
4
+ const codes = (diagnostics: readonly CheckDiagnostic[]): string[] => diagnostics.map(item => item.code)
5
+
6
+ describe('vobs check · VOBS_C210 effect 自订阅', () => {
7
+ it('抓到读 + 写同一个信号', () => {
8
+ const found = analyzeSource(`
9
+ import { state, effect } from '@vobs/vobs'
10
+ const count = state(0, 'count')
11
+ effect(() => {
12
+ count.value++
13
+ })
14
+ `, 'a.ts')
15
+ expect(codes(found)).toContain(VOBS_C210)
16
+ const item = found.find(entry => entry.code === VOBS_C210)!
17
+ expect(item.severity).toBe('error')
18
+ expect(item.message).toContain('"count"')
19
+ expect(item.fix).toContain('untrack')
20
+ expect(item.line).toBe(5)
21
+ })
22
+
23
+ it('untrack 包住的写入不报 —— 正是建议的写法', () => {
24
+ const found = analyzeSource(`
25
+ import { state, effect, untrack } from '@vobs/vobs'
26
+ const count = state(0, 'count')
27
+ effect(() => {
28
+ untrack(() => { count.value++ })
29
+ })
30
+ `, 'a.ts')
31
+ expect(codes(found)).not.toContain(VOBS_C210)
32
+ })
33
+
34
+ it('只写不读不算自订阅(例如 effect 里初始化别的信号)', () => {
35
+ const found = analyzeSource(`
36
+ import { state, effect } from '@vobs/vobs'
37
+ const a = state(0)
38
+ const b = state(0)
39
+ effect(() => {
40
+ b.value = 1
41
+ })
42
+ `, 'a.ts')
43
+ expect(codes(found)).not.toContain(VOBS_C210)
44
+ })
45
+
46
+ it('嵌套函数里的读写不算在本次 effect 上', () => {
47
+ const found = analyzeSource(`
48
+ import { state, effect } from '@vobs/vobs'
49
+ const count = state(0)
50
+ effect(() => {
51
+ const bump = () => { count.value++ }
52
+ bump()
53
+ })
54
+ `, 'a.ts')
55
+ expect(codes(found)).not.toContain(VOBS_C210)
56
+ })
57
+
58
+ it('> .set() 也算写入', () => {
59
+ const found = analyzeSource(`
60
+ import { state, effect } from '@vobs/vobs'
61
+ const count = state(0)
62
+ effect(() => {
63
+ if (count.value < 1) count.set(count.value + 1)
64
+ })
65
+ `, 'a.ts')
66
+ expect(codes(found)).toContain(VOBS_C210)
67
+ })
68
+ })
69
+
70
+ describe('vobs check · VOBS_C232 列表写进分支', () => {
71
+ it('三元分支里的 map 会失去 keyed 复用', () => {
72
+ const found = analyzeSource(`
73
+ export function List(props: { ok: boolean; items: string[] }) {
74
+ return <div>{props.ok ? <span>empty</span> : props.items.map(item => <b key={item}>{item}</b>)}</div>
75
+ }
76
+ `, 'a.tsx')
77
+ expect(codes(found)).toContain(VOBS_C232)
78
+ expect(found.find(entry => entry.code === VOBS_C232)?.fix).toContain('直接的')
79
+ })
80
+
81
+ it('&& 右侧的 map 同样报', () => {
82
+ const found = analyzeSource(`
83
+ export function List(props: { items: string[] }) {
84
+ return <div>{props.items.length > 0 && props.items.map(item => <b key={item}>{item}</b>)}</div>
85
+ }
86
+ `, 'a.tsx')
87
+ expect(codes(found)).toContain(VOBS_C232)
88
+ })
89
+
90
+ it('直接的子表达式列表不报 —— 这是推荐写法', () => {
91
+ const found = analyzeSource(`
92
+ export function List(props: { items: string[] }) {
93
+ return <ul>{props.items.map(item => <li key={item}>{item}</li>)}</ul>
94
+ }
95
+ `, 'a.tsx')
96
+ expect(codes(found)).not.toContain(VOBS_C232)
97
+ })
98
+
99
+ it('分支里不是 list 的不报', () => {
100
+ const found = analyzeSource(`
101
+ export function A(props: { ok: boolean }) {
102
+ return <div>{props.ok ? <span>a</span> : <em>b</em>}</div>
103
+ }
104
+ `, 'a.tsx')
105
+ expect(codes(found)).toEqual([])
106
+ })
107
+ })
108
+
109
+ describe('vobs check · VOBS_C118 组件体里读信号', () => {
110
+ it('读信号存进 const 又在 JSX 里用 —— 组件体只跑一次', () => {
111
+ const found = analyzeSource(`
112
+ import type { Signal } from '@vobs/vobs'
113
+ export function Panel(props: { name: Signal<string> }) {
114
+ const current = props.name.value
115
+ return <div>{current}</div>
116
+ }
117
+ `, 'a.tsx')
118
+ expect(codes(found)).toContain(VOBS_C118)
119
+ const item = found.find(entry => entry.code === VOBS_C118)!
120
+ expect(item.severity).toBe('warning')
121
+ expect(item.fix).toContain('memo')
122
+ expect(item.message).toContain('"current"')
123
+ })
124
+
125
+ it('读取直接写在 JSX 表达式里不报', () => {
126
+ const found = analyzeSource(`
127
+ import type { Signal } from '@vobs/vobs'
128
+ export function Panel(props: { name: Signal<string> }) {
129
+ return <div>{props.name.value}</div>
130
+ }
131
+ `, 'a.tsx')
132
+ expect(codes(found)).not.toContain(VOBS_C118)
133
+ })
134
+
135
+ it('memo 派生不报 —— 那是正确写法', () => {
136
+ const found = analyzeSource(`
137
+ import { memo, type Signal } from '@vobs/vobs'
138
+ export function Panel(props: { name: Signal<string> }) {
139
+ const current = memo(() => props.name.value.toUpperCase())
140
+ return <div>{current.value}</div>
141
+ }
142
+ `, 'a.tsx')
143
+ expect(codes(found)).not.toContain(VOBS_C118)
144
+ })
145
+
146
+ it('不是组件的函数不报(不返回 JSX)', () => {
147
+ const found = analyzeSource(`
148
+ import type { Signal } from '@vobs/vobs'
149
+ export function read(signal: Signal<string>) {
150
+ const current = signal.value
151
+ return current.toUpperCase()
152
+ }
153
+ `, 'a.ts')
154
+ expect(codes(found)).not.toContain(VOBS_C118)
155
+ })
156
+
157
+ /*
158
+ * 回归:这条曾经在真实项目里刷出 21 条误报。
159
+ * `const handler = () => { signal.value }` 里的读取是**延迟**的(点击时才读),
160
+ * 完全正常 —— 第一版的遍历会钻进函数体,把所有事件处理器都报成「组件体里读信号」。
161
+ */
162
+ it('事件处理器里的读取不报 —— 那是延迟读取', () => {
163
+ const found = analyzeSource(`
164
+ import { state } from '@vobs/vobs'
165
+ const count = state(0)
166
+ export function Panel() {
167
+ const bump = () => { count.value++ }
168
+ const simulateMismatch = () => { const current = count.value; return current + 1 }
169
+ return <button onClick={bump}>{simulateMismatch()}</button>
170
+ }
171
+ `, 'a.tsx')
172
+ expect(codes(found)).not.toContain(VOBS_C118)
173
+ })
174
+ })
175
+
176
+ describe('vobs check · 汇总', () => {
177
+ it('干净文件没有问题', () => {
178
+ const found = analyzeSource(`
179
+ import { state, memo } from '@vobs/vobs'
180
+ const count = state(0)
181
+ const doubled = memo(() => count.value * 2)
182
+ export function Counter() {
183
+ return <button onClick={() => count.value++}>{doubled.value}</button>
184
+ }
185
+ `, 'a.tsx')
186
+ expect(found).toEqual([])
187
+ })
188
+
189
+ it('诊断按位置排序,且带可定位的 snippet', () => {
190
+ const found = analyzeSource(`
191
+ import { state, effect } from '@vobs/vobs'
192
+ const count = state(0)
193
+ effect(() => { count.value++ })
194
+ `, 'a.ts')
195
+ expect(found.length).toBeGreaterThan(0)
196
+ expect(found[0]?.snippet).toContain('count.value++')
197
+ const lines = found.map(item => item.line)
198
+ expect([...lines].sort((a, b) => a - b)).toEqual(lines)
199
+ })
200
+ })
201
+
202
+ /*
203
+ * 行内抑制。三条规则都是启发式的(靠名字与形状判断),有些合法代码恰好长成那样 ——
204
+ * 检查器没有逃生口的话,用户只能关掉整条规则,那比漏报更糟。
205
+ */
206
+ describe('vobs check · 行内抑制', () => {
207
+ const 自订阅 = (前一行: string) => `
208
+ import { state, effect } from '@vobs/vobs'
209
+ const count = state(0, 'count')
210
+ effect(() => {
211
+ ${前一行}
212
+ count.value++
213
+ })
214
+ `
215
+
216
+ it('上一行的 // vobs-check-ignore-next-line 抑制下一行', () => {
217
+ expect(codes(analyzeSource(自订阅(' // vobs-check-ignore-next-line'), 'a.ts'))).not.toContain(VOBS_C210)
218
+ })
219
+
220
+ it('没有注释时照常报', () => {
221
+ expect(codes(analyzeSource(自订阅(' // 只是普通注释'), 'a.ts'))).toContain(VOBS_C210)
222
+ })
223
+
224
+ it('只抑制「下一行」,不会顺带放过别的行', () => {
225
+ // 第 5 行写了自订阅、第 7 行也写一个:抑制注释放在第 4 行只该影响第 5 行
226
+ const source = `
227
+ import { state, effect } from '@vobs/vobs'
228
+ const count = state(0, 'count')
229
+ // vobs-check-ignore-next-line
230
+ effect(() => { count.value++ })
231
+ effect(() => { count.value++ })
232
+ `
233
+ const found = analyzeSource(source, 'a.ts')
234
+ expect(codes(found)).toContain(VOBS_C210)
235
+ expect(found.filter(item => item.code === VOBS_C210).length).toBe(1)
236
+ expect(found.find(item => item.code === VOBS_C210)?.line).toBe(6)
237
+ })
238
+ })