@lqc123qwe/car-runtime 1.9.0 → 1.11.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 (182) hide show
  1. package/README.md +17 -1
  2. package/dist/SHA256SUMS +1 -0
  3. package/dist/authz/authz.d.ts +46 -0
  4. package/dist/authz/authz.js +76 -0
  5. package/dist/car-runtime-1.11.0.tgz +0 -0
  6. package/dist/car-runtime-1.11.0.tgz.sig.bundle +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +825 -0
  9. package/dist/dx/doctor.d.ts +62 -0
  10. package/dist/dx/doctor.js +127 -0
  11. package/dist/governance/dump.d.ts +47 -0
  12. package/dist/governance/dump.js +17 -0
  13. package/dist/host/facade.d.ts +13 -0
  14. package/dist/host/facade.js +154 -0
  15. package/dist/host/hostGateway.d.ts +117 -0
  16. package/dist/host/hostGateway.js +65 -0
  17. package/dist/host/mappings.d.ts +27 -0
  18. package/dist/host/mappings.js +52 -0
  19. package/dist/host/stdio.d.ts +14 -0
  20. package/dist/host/stdio.js +83 -0
  21. package/dist/kernel/context.d.ts +76 -0
  22. package/dist/kernel/context.js +203 -0
  23. package/dist/kernel/events.d.ts +37 -0
  24. package/dist/kernel/events.js +82 -0
  25. package/dist/load/config.d.ts +79 -0
  26. package/dist/load/config.js +285 -0
  27. package/dist/load/loader.d.ts +72 -0
  28. package/dist/load/loader.js +162 -0
  29. package/dist/load/registry.d.ts +44 -0
  30. package/dist/load/registry.js +58 -0
  31. package/dist/load/report.d.ts +98 -0
  32. package/dist/load/report.js +301 -0
  33. package/dist/load/sigGate.d.ts +22 -0
  34. package/dist/load/sigGate.js +68 -0
  35. package/dist/load/sign.d.ts +25 -0
  36. package/dist/load/sign.js +47 -0
  37. package/dist/load/verifier.d.ts +35 -0
  38. package/dist/load/verifier.js +56 -0
  39. package/dist/loop/goal.d.ts +31 -0
  40. package/dist/loop/goal.js +34 -0
  41. package/dist/loop/recover.d.ts +17 -0
  42. package/dist/loop/recover.js +39 -0
  43. package/dist/loop/stop.d.ts +82 -0
  44. package/dist/loop/stop.js +184 -0
  45. package/dist/mcp/gateway.d.ts +96 -0
  46. package/dist/mcp/gateway.js +69 -0
  47. package/dist/mcp/httpClient.d.ts +27 -0
  48. package/dist/mcp/httpClient.js +196 -0
  49. package/dist/mcp/stdioClient.d.ts +8 -0
  50. package/dist/mcp/stdioClient.js +73 -0
  51. package/dist/ptc/budget.d.ts +21 -0
  52. package/dist/ptc/budget.js +36 -0
  53. package/dist/ptc/erasable.d.ts +14 -0
  54. package/dist/ptc/erasable.js +27 -0
  55. package/dist/ptc/runCode.d.ts +65 -0
  56. package/dist/ptc/runCode.js +140 -0
  57. package/dist/ptc/schemaRender.d.ts +15 -0
  58. package/dist/ptc/schemaRender.js +90 -0
  59. package/dist/ptc/sdk.d.ts +27 -0
  60. package/dist/ptc/sdk.js +23 -0
  61. package/dist/ptc/worker-entry.d.ts +1 -0
  62. package/dist/ptc/worker-entry.js +71 -0
  63. package/dist/release-audit.jsonl +4 -0
  64. package/dist/runtime-core/chatStep.d.ts +43 -0
  65. package/dist/runtime-core/chatStep.js +121 -0
  66. package/dist/runtime-core/credentials.d.ts +49 -0
  67. package/dist/runtime-core/credentials.js +118 -0
  68. package/dist/runtime-core/errors.d.ts +30 -0
  69. package/dist/runtime-core/errors.js +36 -0
  70. package/dist/runtime-core/llm.d.ts +76 -0
  71. package/dist/runtime-core/llm.js +638 -0
  72. package/dist/runtime-core/redaction.d.ts +39 -0
  73. package/dist/runtime-core/redaction.js +79 -0
  74. package/dist/runtime-core/telemetry.d.ts +88 -0
  75. package/dist/runtime-core/telemetry.js +332 -0
  76. package/dist/runtime-core/tools.d.ts +15 -0
  77. package/dist/runtime-core/tools.js +12 -0
  78. package/dist/runtime-core/trace.d.ts +55 -0
  79. package/dist/runtime-core/trace.js +137 -0
  80. package/dist/runtime-core/types.d.ts +104 -0
  81. package/dist/runtime-core/types.js +13 -0
  82. package/dist/sandbox/sandbox.d.ts +68 -0
  83. package/dist/sandbox/sandbox.js +153 -0
  84. package/dist/sbom.json +263 -0
  85. package/dist/seam.d.ts +24 -0
  86. package/dist/seam.js +23 -0
  87. package/dist/security/secrets-baseline.d.ts +43 -0
  88. package/dist/security/secrets-baseline.js +176 -0
  89. package/dist/security/secrets.d.ts +45 -0
  90. package/dist/security/secrets.js +138 -0
  91. package/dist/session/export.d.ts +55 -0
  92. package/dist/session/export.js +81 -0
  93. package/dist/session/fork.d.ts +51 -0
  94. package/dist/session/fork.js +63 -0
  95. package/dist/session/format.d.ts +21 -0
  96. package/dist/session/format.js +49 -0
  97. package/dist/session/indexStore.d.ts +44 -0
  98. package/dist/session/indexStore.js +265 -0
  99. package/dist/session/log.d.ts +79 -0
  100. package/dist/session/log.js +157 -0
  101. package/dist/session/store.d.ts +9 -0
  102. package/dist/session/store.js +37 -0
  103. package/dist/telemetry/metrics.d.ts +25 -0
  104. package/dist/telemetry/metrics.js +26 -0
  105. package/package.json +17 -1
  106. package/src/authz/authz.ts +12 -0
  107. package/src/loop/stop.ts +43 -10
  108. package/src/runtime-core/chatStep.ts +22 -2
  109. package/src/runtime-core/llm.ts +96 -13
  110. package/src/runtime-core/types.ts +22 -1
  111. package/src/seam.ts +24 -0
  112. package/src/session/log.ts +1 -1
  113. package/.gitattributes +0 -11
  114. package/.github/workflows/ci.yml +0 -293
  115. package/.github/workflows/release.yml +0 -54
  116. package/adr/ADR-001.md +0 -4
  117. package/adr/ADR-002.md +0 -5
  118. package/adr/ADR-003.md +0 -35
  119. package/adr/host-onboarding.md +0 -68
  120. package/adr/mlps-audit-checklist.md +0 -41
  121. package/adr/win32-spike.md +0 -45
  122. package/docs/plugin-authoring.md +0 -82
  123. package/native/landlock-run/Makefile +0 -19
  124. package/native/landlock-run/landlock-run.c +0 -162
  125. package/scripts/contract-check.ts +0 -56
  126. package/scripts/invariant-n1.ts +0 -96
  127. package/scripts/package-check.ts +0 -29
  128. package/scripts/ptc-baseline/run-baseline.ts +0 -118
  129. package/scripts/release-artifacts.ts +0 -111
  130. package/scripts/release-pipeline.ts +0 -245
  131. package/scripts/secrets-baseline.ts +0 -33
  132. package/scripts/secrets-scan.ts +0 -56
  133. package/scripts/win32-ffi-debug.ts +0 -143
  134. package/scripts/win32-koffi-spike.ts +0 -537
  135. package/test/contract/eventmap.golden.json +0 -7
  136. package/test/fixtures/hello-plugin.ts +0 -5
  137. package/test/kernel.spec.ts +0 -136
  138. package/test/registry-e2e/run-e2e.ts +0 -213
  139. package/test/s11.spec.ts +0 -129
  140. package/test/s12.spec.ts +0 -135
  141. package/test/s13.spec.ts +0 -117
  142. package/test/s14.spec.ts +0 -117
  143. package/test/s15.spec.ts +0 -80
  144. package/test/s16.spec.ts +0 -133
  145. package/test/s17.spec.ts +0 -102
  146. package/test/s18.spec.ts +0 -81
  147. package/test/s19.spec.ts +0 -138
  148. package/test/s2.spec.ts +0 -245
  149. package/test/s20.spec.ts +0 -241
  150. package/test/s21.spec.ts +0 -231
  151. package/test/s22-m7-store-format.spec.ts +0 -135
  152. package/test/s23-m7-export-index.spec.ts +0 -189
  153. package/test/s24-signature-gate.spec.ts +0 -185
  154. package/test/s25-m8-runtime-core.spec.ts +0 -600
  155. package/test/s26-signature-closeout.spec.ts +0 -349
  156. package/test/s27-host-load-wiring.spec.ts +0 -134
  157. package/test/s28-session-index-followup.spec.ts +0 -236
  158. package/test/s29-otel-producer.spec.ts +0 -252
  159. package/test/s3.spec.ts +0 -179
  160. package/test/s30-release-artifacts.spec.ts +0 -127
  161. package/test/s31-real-model-wiring.spec.ts +0 -329
  162. package/test/s32-telemetry-mcp-ptc.spec.ts +0 -218
  163. package/test/s33-anthropic-adapter.spec.ts +0 -354
  164. package/test/s34-mcp-remote-transport.spec.ts +0 -250
  165. package/test/s35-config-ts-zod.spec.ts +0 -145
  166. package/test/s36-host-tool-surface.spec.ts +0 -250
  167. package/test/s37-projection-dedupe.spec.ts +0 -161
  168. package/test/s38-config-jiti.spec.ts +0 -116
  169. package/test/s39-otlp-span-semantics.spec.ts +0 -100
  170. package/test/s4.spec.ts +0 -169
  171. package/test/s40-metrics-cumulative.spec.ts +0 -67
  172. package/test/s41-mcp-serve-turn-span.spec.ts +0 -148
  173. package/test/s42-traceparent-propagation.spec.ts +0 -415
  174. package/test/s43-remote-http-traceparent.spec.ts +0 -262
  175. package/test/s5.spec.ts +0 -95
  176. package/test/s6.spec.ts +0 -169
  177. package/test/s7.spec.ts +0 -136
  178. package/test/s7b-baseline.spec.ts +0 -20
  179. package/test/s9.spec.ts +0 -89
  180. package/test/sandbox-escape/j17-orchestrate.sh +0 -30
  181. package/test/sandbox-escape/run-matrix.sh +0 -98
  182. package/tsconfig.json +0 -18
package/test/s19.spec.ts DELETED
@@ -1,138 +0,0 @@
1
- import { test } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { SessionLog } from '../src/session/log.ts'
4
- import { forkCrossHost, loadForkedLog } from '../src/session/fork.ts'
5
- import { deriveSessionId, HOST_MAPPINGS } from '../src/host/mappings.ts'
6
- import { createRuntimeFacade } from '../src/host/facade.ts'
7
- import { HostGateway } from '../src/host/hostGateway.ts'
8
-
9
- // ==================== forkCrossHost 核心原语 ====================
10
-
11
- function sourceLog(): SessionLog {
12
- const log = new SessionLog(deriveSessionId('claude-code', 'sess-A'))
13
- log.append('user', 'user', 'T1', { text: '帮我校对这段话' })
14
- log.append('model', 'assistant', 'T1', { text: '好的,请贴出原文' })
15
- log.append('model', 'toolCall', 'T2', { tool: 'fs_read', id: 'c1' })
16
- return log
17
- }
18
-
19
- test('fork 跨宿主: 链逐字节迁移 + fork 标记落链 + 目标会话 id 确定性派生', () => {
20
- const src = sourceLog()
21
- const fork = forkCrossHost(src, { hostId: 'codex', hostSessionId: 'cx-9' })
22
- assert.equal(fork.sessionId, deriveSessionId('codex', 'cx-9'))
23
- assert.equal(fork.migrated, 3)
24
- assert.equal(fork.log.events.length, 4, '3 迁移 + 1 fork 标记')
25
- assert.equal(fork.log.verifyChain(), null, '迁移后链完整')
26
- const marker = fork.log.events[3]!
27
- assert.equal(marker.kind, 'fork')
28
- assert.deepEqual(marker.payload, {
29
- fromSessionId: src.sessionId, targetHostId: 'codex', targetHostSessionId: 'cx-9',
30
- upToSeq: 3, fromTailHash: src.events[2]!.hash,
31
- })
32
- })
33
-
34
- test('fork 跨宿主: 源链断链拒绝 fork(fail-visible,破损链不扩散)', () => {
35
- const src = sourceLog()
36
- ;(src.events as unknown as Array<{ payload: unknown }>)[1]!.payload = { text: '被篡改' }
37
- assert.throws(() => forkCrossHost(src, { hostId: 'codex', hostSessionId: 'cx-9' }), /chain broken at seq 1/)
38
- })
39
-
40
- test('fork 跨宿主: upToSeq 部分迁移(前缀 fork)+ 越界显式报错', () => {
41
- const fork = forkCrossHost(sourceLog(), { hostId: 'codex', hostSessionId: 'cx-1' }, { upToSeq: 2 })
42
- assert.equal(fork.migrated, 2)
43
- assert.equal(fork.log.events.length, 3)
44
- assert.equal((fork.log.events[2]!.payload as { upToSeq: number }).upToSeq, 2)
45
- assert.throws(() => forkCrossHost(sourceLog(), { hostId: 'codex', hostSessionId: 'cx-2' }, { upToSeq: 99 }), /out of range/)
46
- assert.throws(() => forkCrossHost(sourceLog(), { hostId: 'codex', hostSessionId: 'cx-3' }, { upToSeq: -1 }), /out of range/)
47
- })
48
-
49
- // ==================== loadForkedLog resume 侧装载 ====================
50
-
51
- test('resume 装载: 工件 → 链校验 → 归属校验 → 续跑 append 不断链', () => {
52
- const src = sourceLog()
53
- const fork = forkCrossHost(src, { hostId: 'codex', hostSessionId: 'cx-9' })
54
- const loaded = loadForkedLog(fork.jsonl, { hostId: 'codex', hostSessionId: 'cx-9' })
55
- assert.equal(loaded.sessionId, fork.sessionId)
56
- assert.equal(loaded.log.verifyChain(), null)
57
- // 续跑:装载后 append 正常接链(#tail 已恢复)
58
- loaded.log.append('user', 'user', 'T2', { text: '继续' })
59
- assert.equal(loaded.log.verifyChain(), null, '续跑后链仍完整')
60
- assert.equal(loaded.log.events.length, 5)
61
- // 投影:源会话消息在宿主 B 可见(续跑上下文完整)
62
- const msgs = loaded.log.deriveMessages()
63
- assert.equal((msgs[0] as { content: { text: string } }).content.text, '帮我校对这段话')
64
- })
65
-
66
- test('resume 装载: 断链工件 / 非 fork 工件 / 归属不匹配 全部拒绝(零静默)', () => {
67
- const fork = forkCrossHost(sourceLog(), { hostId: 'codex', hostSessionId: 'cx-9' })
68
- // ① 篡改中间事件 → 断链
69
- const tampered = fork.jsonl.split('\n').filter(Boolean).map((l, i) => i === 1 ? l.replace('好的', '已篡改') : l).join('\n')
70
- assert.throws(() => loadForkedLog(tampered), /chain broken/)
71
- // ② 普通 SessionLog 导出(无 fork 标记)拒绝
72
- const plain = new SessionLog('S-plain')
73
- plain.append('user', 'user', 'T1', { text: 'hi' })
74
- assert.throws(() => loadForkedLog(plain.exportJSONL()), /no fork marker/)
75
- // ③ 工件归属 codex/cx-9,却被 claude-code/cc-1 装载 → 拒绝
76
- assert.throws(() => loadForkedLog(fork.jsonl, { hostId: 'claude-code', hostSessionId: 'cc-1' }), /target mismatch/)
77
- // ④ 归属匹配 → 通过
78
- assert.doesNotThrow(() => loadForkedLog(fork.jsonl, { hostId: 'codex', hostSessionId: 'cx-9' }))
79
- })
80
-
81
- // ==================== 工具面 E2E:宿主 A fork → 工件 → 宿主 B 导入续跑 ====================
82
-
83
- function facadeGateway() {
84
- const profiles = new Map(HOST_MAPPINGS.map(h => [h.hostId, h]))
85
- const facade = createRuntimeFacade({ profiles })
86
- const gw = new HostGateway({ facade, audit: () => {} })
87
- for (const h of HOST_MAPPINGS) gw.registerHost({ hostId: h.hostId, profile: h, transport: {} as never })
88
- return { facade, gw }
89
- }
90
-
91
- test('E2E 跨宿主: 宿主 A 会话 → session_fork → 工件 → 宿主 B session_start 导入 → session_turn 续跑', async () => {
92
- const { gw } = facadeGateway()
93
- // 宿主 A(claude-code):start + 一个 turn(产生消息与 toolCall 事件)
94
- const a = await gw.handle('claude-code', 'session_start', { hostSessionId: 'sess-A' })
95
- const aId = (a.result as { sessionId: string }).sessionId
96
- await gw.handle('claude-code', 'session_turn', { sessionId: aId, input: { events: [
97
- { hostEvent: 'user_message', payload: { content: '帮我查库存' } },
98
- { hostEvent: 'tool_use', payload: { tool_name: 'erp_query', tool_call_id: 'c1' } },
99
- { hostEvent: 'turn_complete', payload: {} },
100
- ] } })
101
- // fork 到宿主 B(codex)
102
- const fk = await gw.handle('claude-code', 'session_fork', { sessionId: aId, targetHostId: 'codex', targetHostSessionId: 'cx-77' })
103
- const { sessionId: bId, jsonl } = fk.result as { sessionId: string; jsonl: string }
104
- assert.equal(bId, deriveSessionId('codex', 'cx-77'))
105
- // 宿主 B:导入工件 start(归属校验内建)→ replay 可见源会话消息
106
- const b = await gw.handle('codex', 'session_start', { hostSessionId: 'cx-77', importJsonl: jsonl })
107
- assert.equal((b.result as { sessionId: string }).sessionId, bId)
108
- const replay = await gw.handle('codex', 'session_replay', { sessionId: bId })
109
- const messages = (replay.result as { messages: Array<{ content: unknown }> }).messages
110
- assert.equal((messages[0]!.content as { text: string }).text, '帮我查库存')
111
- // 宿主 B 续跑:turnId 不与迁移段冲突(迁移段最大 T1 → 续跑 T2)
112
- const t2 = await gw.handle('codex', 'session_turn', { sessionId: bId, input: { events: [
113
- { hostEvent: 'input_item', payload: { text: '继续查' } },
114
- { hostEvent: 'task_complete', payload: {} },
115
- ] } })
116
- assert.equal((t2.result as { reason: string }).reason, 'completed')
117
- // 链完整性:导入 + 续跑后 verify 通过
118
- const vf = await gw.handle('codex', 'session_verify', { sessionId: bId })
119
- assert.equal((vf.result as { ok: boolean }).ok, true)
120
- })
121
-
122
- test('E2E 跨宿主负例: 工件被第三方宿主导入 → 归属校验拒绝(不静默降级为普通会话)', async () => {
123
- const { gw } = facadeGateway()
124
- const a = await gw.handle('claude-code', 'session_start', { hostSessionId: 'sess-A' })
125
- const aId = (a.result as { sessionId: string }).sessionId
126
- const fk = await gw.handle('claude-code', 'session_fork', { sessionId: aId, targetHostId: 'codex', targetHostSessionId: 'cx-77' })
127
- const { jsonl } = fk.result as { jsonl: string }
128
- // 第三方宿主(未登记 profile 或归属不匹配)导入 → 显式报错
129
- await assert.rejects(
130
- () => gw.handle('codex', 'session_start', { hostSessionId: 'other-id', importJsonl: jsonl }).then(r => { if (r.error) throw new Error(r.error) }),
131
- /target mismatch|CAR-E-FORK/,
132
- )
133
- // fork 到未登记目标宿主 → 显式报错
134
- await assert.rejects(
135
- () => gw.handle('claude-code', 'session_fork', { sessionId: aId, targetHostId: 'ghost-host', targetHostSessionId: 'x' }).then(r => { if (r.error) throw new Error(r.error) }),
136
- /not registered/,
137
- )
138
- })
package/test/s2.spec.ts DELETED
@@ -1,245 +0,0 @@
1
- import { test } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { declareEvent, EventBus } from '../src/kernel/events.ts'
4
- import { runTurn, type ModelStep, type ToolDef, type ToolCall } from '../src/loop/stop.ts'
5
- import { SessionLog } from '../src/session/log.ts'
6
- import { mountPlugin, parseManifest } from '../src/load/loader.ts'
7
- import { writeFileSync, rmSync } from 'node:fs'
8
-
9
- // ==================== F7 事件分发契约 ====================
10
-
11
- test('F7: 未声明事件的订阅 = 启动期静态报错(US-7 AC2)', () => {
12
- const bus = new EventBus()
13
- assert.throws(() => bus.on('undeclared/event', () => {}), /undeclared event/)
14
- })
15
-
16
- test('F7: bail 模式——首断即止(短路是设计意图)', async () => {
17
- declareEvent('guard/check', 'bail')
18
- const bus = new EventBus()
19
- const calls: string[] = []
20
- bus.on('guard/check', () => { calls.push('h1'); return 'BLOCK' })
21
- bus.on('guard/check', () => { calls.push('h2'); return 'BLOCK2' })
22
- const r = await bus.dispatch('guard/check', {})
23
- assert.equal(r, 'BLOCK')
24
- assert.deepEqual(calls, ['h1'])
25
- })
26
-
27
- test('F7: serial 模式——有序检查点链式传值', async () => {
28
- declareEvent('ctx/enrich', 'serial')
29
- const bus = new EventBus()
30
- bus.on('ctx/enrich', (_p: unknown, next: () => string) => next() + '!A')
31
- bus.on('ctx/enrich', (_p: unknown, next: () => string) => next() + '!B')
32
- assert.equal(await bus.dispatch<string>('ctx/enrich', 'x', () => 'base'), 'base!A!B')
33
- })
34
-
35
- test('F7: waterfall 模式——环绕中间件(next() 前后均可介入)', async () => {
36
- declareEvent('tools/around', 'waterfall')
37
- const bus = new EventBus()
38
- const trace: string[] = []
39
- bus.on('tools/around', async (_p: unknown, next: () => Promise<string>) => {
40
- trace.push('before')
41
- const r = await next()
42
- trace.push('after')
43
- return r.toUpperCase()
44
- })
45
- bus.on('tools/around', () => Promise.resolve('raw'))
46
- const r = await bus.dispatch<string>('tools/around', {})
47
- assert.equal(r, 'RAW')
48
- assert.deepEqual(trace, ['before', 'after'])
49
- })
50
-
51
- test('F7: parallel 模式扇出聚合;emit 模式广播无返回', async () => {
52
- declareEvent('audit/fanout', 'parallel')
53
- declareEvent('lifecycle/notify', 'emit')
54
- const bus = new EventBus()
55
- bus.on('audit/fanout', (p: number) => p * 2)
56
- bus.on('audit/fanout', (p: number) => p * 3)
57
- assert.deepEqual(await bus.dispatch<number, number>('audit/fanout', 5), [10, 15])
58
- const seen: number[] = []
59
- bus.on('lifecycle/notify', () => { seen.push(1) })
60
- assert.equal(await bus.dispatch('lifecycle/notify', {}), undefined)
61
- assert.deepEqual(seen, [1])
62
- })
63
-
64
- test('F7: 契约重复声明且模式不一致 = 显式报错', () => {
65
- declareEvent('dup/e', 'serial')
66
- assert.throws(() => declareEvent('dup/e', 'bail'), /re-declared/)
67
- })
68
-
69
- // ==================== F5 停止语义(ADR-001) ====================
70
-
71
- test('F5 AC1: 工具批次 → step null 续走,无异常', async () => {
72
- const log = new SessionLog()
73
- let n = 0
74
- const tools = new Map([['t', { declaredSideEffect: 'readonly', run: async () => 'ok' } as ToolDef]])
75
- const r = await runTurn({
76
- log, turnId: 'T', tools,
77
- model: async (): Promise<ModelStep> => n++ === 0
78
- ? { stopReason: 'toolUse', toolCalls: [{ id: 'c1', tool: 't', args: {} }] }
79
- : { stopReason: 'stop', text: 'done' },
80
- })
81
- assert.equal(r.reason, 'completed')
82
- const kinds = log.events.map(e => e.kind)
83
- assert.ok(kinds.includes('toolCall') && kinds.includes('toolResult'))
84
- })
85
-
86
- test('F5 AC2: step1 max-tokens + steering 续跑 → turn/end 保留 max-tokens(不被冲销)', async () => {
87
- const log = new SessionLog()
88
- let n = 0
89
- const r = await runTurn({
90
- log, turnId: 'T',
91
- model: async (): Promise<ModelStep> => n++ === 0 ? { stopReason: 'length' } : { stopReason: 'stop', text: 'recovered' },
92
- })
93
- assert.equal(r.reason, 'max-tokens')
94
- const end = log.events.find(e => e.kind === 'turnEnd') as any
95
- assert.equal(end.meta.reason, 'max-tokens')
96
- })
97
-
98
- test('F5 AC3: 截断响应含写工具 → 解析前收口(无副作用落地)', async () => {
99
- const log = new SessionLog()
100
- let executed = false
101
- const tools = new Map([['writeFile', { declaredSideEffect: 'write', run: async () => { executed = true } } as ToolDef]])
102
- const r = await runTurn({
103
- log, turnId: 'T', tools,
104
- model: async (): Promise<ModelStep> => ({ stopReason: 'length', truncatedTools: [{ id: 'c1', tool: 'writeFile' }] }),
105
- })
106
- assert.equal(r.reason, 'max-tokens')
107
- assert.equal(executed, false)
108
- })
109
-
110
- test('F5 AC4: 只读白名单 + retryReadOnly → 补错重试(Pi 式)', async () => {
111
- const log = new SessionLog()
112
- let n = 0
113
- const tools = new Map([['search', { declaredSideEffect: 'readonly', run: async () => 'results' } as ToolDef]])
114
- const r = await runTurn({
115
- log, turnId: 'T', tools,
116
- preset: { maxTokensRetryReadOnly: true },
117
- model: async (): Promise<ModelStep> => {
118
- if (n++ === 0) return { stopReason: 'length', truncatedTools: [{ id: 'c1', tool: 'search' }] }
119
- return { stopReason: 'stop', text: 'done' }
120
- },
121
- })
122
- // I4 一致性:即便只读重试续走,turn/end 仍保留 max-tokens(与 AC2 同语义)
123
- assert.equal(r.reason, 'max-tokens')
124
- assert.equal(r.steps, 2, '重试后续走了一个 step')
125
- const err = log.events.find(e => e.kind === 'toolResult' && JSON.stringify((e.payload as any)?.error || '').includes('truncated'))
126
- assert.ok(err, '只读工具收到截断错误结果')
127
- })
128
-
129
- test('F5: 取消 → 未派发调用补记成对事件,reason=aborted(日志无缺口)', async () => {
130
- const log = new SessionLog()
131
- const signal = { aborted: false }
132
- let i = 0
133
- const tools = new Map([['t1', { declaredSideEffect: 'readonly', run: async () => { if (++i === 1) signal.aborted = true; return 'x' } } as ToolDef]])
134
- const r = await runTurn({
135
- log, turnId: 'T', tools, signal,
136
- model: async (): Promise<ModelStep> => ({ stopReason: 'toolUse', toolCalls: [
137
- { id: 'c1', tool: 't1', args: {} }, { id: 'c2', tool: 't1', args: {} },
138
- ] }),
139
- })
140
- assert.equal(r.reason, 'aborted')
141
- // 日志无缺口:c2 的 toolCall 与合成错误 toolResult 成对存在
142
- const c2call = log.events.some(e => e.kind === 'toolCall' && (e.payload as any).id === 'c2')
143
- const c2result = log.events.some(e => e.kind === 'toolResult' && (e.payload as any).id === 'c2' && (e.payload as any).error === 'aborted-before-dispatch')
144
- assert.ok(c2call && c2result, '未派发调用补记成对事件')
145
- })
146
-
147
- test('F5: concludesTurn=OR——任一工具收口即 completed', async () => {
148
- const log = new SessionLog()
149
- const tools = new Map([
150
- ['commit', { declaredSideEffect: 'write', concludesTurn: true, run: async () => 'committed' } as ToolDef],
151
- ['probe', { declaredSideEffect: 'readonly', run: async () => 'p' } as ToolDef],
152
- ])
153
- const r = await runTurn({
154
- log, turnId: 'T', tools, preset: { mode: 'full' },
155
- model: async (): Promise<ModelStep> => ({ stopReason: 'toolUse', toolCalls: [
156
- { id: 'c1', tool: 'probe', args: {} }, { id: 'c2', tool: 'commit', args: {} },
157
- ] }),
158
- })
159
- assert.equal(r.reason, 'completed')
160
- })
161
-
162
- test('F5: terminate=AND——整批全部 terminate 才 aborted;权限门拒绝写操作', async () => {
163
- const log = new SessionLog()
164
- const tools = new Map([
165
- ['danger', { declaredSideEffect: 'write', terminate: true, run: async () => 'never' } as ToolDef],
166
- ])
167
- let authorizeCalled = false
168
- let n = 0
169
- const r = await runTurn({
170
- log, turnId: 'T', tools,
171
- preset: { mode: 'confirm', authorize: async () => { authorizeCalled = true; return false } },
172
- model: async (): Promise<ModelStep> => n++ === 0
173
- ? { stopReason: 'toolUse', toolCalls: [{ id: 'c1', tool: 'danger', args: {} }] }
174
- : { stopReason: 'stop', text: 'acknowledged' },
175
- })
176
- assert.equal(r.reason, 'completed') // 单工具被拒后无收口信号 → 模型继续 → stop
177
- assert.equal(authorizeCalled, true)
178
- const denied = log.events.some(e => e.kind === 'toolResult' && (e.payload as any)?.error === 'authorization-denied')
179
- assert.ok(denied, '拒绝记录落日志')
180
- })
181
-
182
- // ==================== F6 装配层(Stub/invalidate/manifest) ====================
183
-
184
- test('F6: manifest 校验——version 非精确 semver = 冲突链报错', () => {
185
- assert.throws(() => parseManifest({ name: 'p', version: '^1.0.0' }, 'plugin.ts'), /exact semver.*plugin\.ts/)
186
- assert.throws(() => parseManifest({ version: '1.0.0' }, 'plugin.ts'), /"name" is required/)
187
- assert.throws(() => parseManifest({ name: 'p', version: '1.0.0', peerPolicyOverride: { relaxed: true } as any }, 'm.ts'), /reason.*required/)
188
- const ok = parseManifest({ name: 'p', version: '1.0.0' }, 'm.ts')
189
- assert.equal(ok.name, 'p')
190
- })
191
-
192
- test('F6: Stub 占位——bindCore 前调用显式抛错,bind 后可用;invalidate 后再访问报错', async () => {
193
- const file = new URL('./fixture-plugin.ts', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
194
- writeFileSync(file, `export default function apply(api) {\n api.registerTool({ name: 'tool-v1', run: async () => 'v1' })\n}\n`)
195
- try {
196
- const p = await mountPlugin({ file, manifest: { name: 'hot', version: '1.0.0' } })
197
- // 注册类动作 Stub 期可用(收集待注册项);能力查询 bind 前显式抛错
198
- assert.throws(() => p.api.getRegisteredTools(), /CAR-STUB/)
199
- const hostTools: Array<{ name: string }> = []
200
- p.bindCore({
201
- registerTool: (t) => { hostTools.push(t) },
202
- getRegisteredTools: () => hostTools,
203
- })
204
- // bind 后冲刷 pending 注册
205
- assert.deepEqual(p.api.getRegisteredTools().map((t) => t.name), ['tool-v1'])
206
- assert.deepEqual(hostTools.map((t) => t.name), ['tool-v1'])
207
- p.invalidate()
208
- assert.throws(() => p.api.getRegisteredTools(), /CAR-INVALIDATED/)
209
- } finally { try { rmSync(file, { force: true }) } catch {} }
210
- })
211
-
212
- test('F6: 热重载——同文件 epoch 击穿缓存,新代码生效 + 旧句柄失效', async () => {
213
- const file = new URL('./fixture-reload.ts', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
214
- let p1: Awaited<ReturnType<typeof mountPlugin>> | undefined
215
- try {
216
- writeFileSync(file, `export default function apply(api) { api.registerTool({ name: 'tool-v1', run: async () => 1 }) }\n`)
217
- p1 = await mountPlugin({ file, manifest: { name: 'r', version: '1.0.0' } })
218
- p1.bindCore({
219
- registerTool(t: { name: string }) {},
220
- getRegisteredTools: () => [{ name: 'tool-v1' }],
221
- })
222
- assert.deepEqual(p1.api.getRegisteredTools(), [{ name: 'tool-v1' }])
223
- // 重写源文件 → epoch+1 重载
224
- writeFileSync(file, `export default function apply(api) { api.registerTool({ name: 'tool-v2', run: async () => 2 }) }\n`)
225
- const p2 = await mountPlugin({ file, manifest: { name: 'r', version: '1.0.1' }, reloadEpoch: 1 })
226
- p2.bindCore({
227
- registerTool(t: { name: string }) {},
228
- getRegisteredTools: () => [{ name: 'tool-v2' }],
229
- })
230
- assert.deepEqual(p2.api.getRegisteredTools(), [{ name: 'tool-v2' }])
231
- p1.invalidate()
232
- assert.throws(() => p1!.api.getRegisteredTools(), /CAR-INVALIDATED/)
233
- } finally {
234
- try { rmSync(file, { force: true }) } catch {}
235
- void p1
236
- }
237
- })
238
-
239
- test('F6: 非工厂默认导出 = 冲突链报错', async () => {
240
- const file = new URL('./fixture-bad.ts', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
241
- writeFileSync(file, `export default 42\n`)
242
- try {
243
- await assert.rejects(() => mountPlugin({ file, manifest: { name: 'b', version: '1.0.0' } }), /default-export a factory/)
244
- } finally { try { rmSync(file, { force: true }) } catch {} }
245
- })
package/test/s20.spec.ts DELETED
@@ -1,241 +0,0 @@
1
- import { test } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'
4
- import { tmpdir } from 'node:os'
5
- import { join } from 'node:path'
6
- import { loadPlugins, renderLoadReport, ReloadManager, formatLoadReport, newInstallId } from '../src/load/report.ts'
7
- import { doctorCredentials, doctorConnectivity } from '../src/dx/doctor.ts'
8
- import type { LoadReportVO } from '../src/load/report.ts'
9
-
10
- // ==================== fixture 工具(红线 8:清理 try/catch 容错) ====================
11
-
12
- const PLUGIN_BODY = (tool: string) =>
13
- `export default function apply(api) {\n api.registerTool({ name: '${tool}', run: async () => 'ok' })\n}\n`
14
-
15
- function makePlugin(dir: string, file: string, manifest: Record<string, unknown>, tool: string): string {
16
- const p = join(dir, file)
17
- writeFileSync(p, `export const manifest = ${JSON.stringify(manifest)}\n${PLUGIN_BODY(tool)}\n`)
18
- return p
19
- }
20
-
21
- function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
22
- const dir = mkdtempSync(join(tmpdir(), `car-s20-${name}-`))
23
- return Promise.resolve()
24
- .then(() => fn(dir))
25
- .finally(() => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8:safe-delete 拦截容错 */ } })
26
- }
27
-
28
- const stageOf = (r: LoadReportVO, s: string) => r.stages.find(x => x.stage === s)!
29
- const stageNames = (r: LoadReportVO) => r.stages.map(s => `${s.stage}:${s.status}`)
30
-
31
- // ==================== 六阶段加载报告 ====================
32
-
33
- test('M6: 六阶段成功路径——全 PASS + LoadReportVO 结构对齐 §3.2.M6.3 + 拓扑序装配', async () => {
34
- await withTempDir('ok', async (dir) => {
35
- makePlugin(dir, 'dep-p.ts', { name: 'dep-p', version: '1.0.0' }, 'dep-tool')
36
- makePlugin(dir, 'consumer.ts', { name: 'consumer', version: '2.0.0', peers: [{ peer: 'dep-p', range: '^1.0.0' }] }, 'con-tool')
37
- const { report, plugins, order } = await loadPlugins({ source: dir })
38
- // 结构逐字对齐:installId / startedTime(ISO) / durationMs / stages×6 / warnings
39
- assert.match(report.installId, /^car-install-/)
40
- assert.equal(Number.isNaN(Date.parse(report.startedTime)), false)
41
- assert.equal(typeof report.durationMs, 'number')
42
- assert.ok(report.durationMs >= 0)
43
- assert.deepEqual(stageNames(report), [
44
- 'discover:PASS', 'verify:PASS', 'parse:PASS', 'validate:PASS', 'topo:PASS', 'register:PASS',
45
- ])
46
- assert.ok(Array.isArray(report.warnings))
47
- // 拓扑序:provider(dep-p)先于 consumer
48
- assert.deepEqual(order.map(m => m.name), ['dep-p', 'consumer'])
49
- assert.equal(plugins.length, 2)
50
- // FAIL 阶段不存在 conflicts/error;PASS 报告无 error 定位
51
- for (const s of report.stages) { assert.equal(s.conflicts, undefined); assert.equal(s.error, undefined) }
52
- })
53
- })
54
-
55
- test('M6: manifest 失败短路——parse FAIL + 文件定位 + 后续阶段 SKIPPED', async () => {
56
- await withTempDir('bad-manifest', async (dir) => {
57
- makePlugin(dir, 'good.ts', { name: 'good', version: '1.0.0' }, 't1')
58
- makePlugin(dir, 'broken.ts', { name: 'broken', version: '^1.0.0' }, 't2') // 非精确 semver
59
- const { report } = await loadPlugins({ source: dir })
60
- assert.deepEqual(stageNames(report), [
61
- 'discover:PASS', 'verify:PASS', 'parse:FAIL', 'validate:SKIPPED', 'topo:SKIPPED', 'register:SKIPPED',
62
- ])
63
- const parse = stageOf(report, 'parse')
64
- assert.match(parse.error!.file, /broken\.ts$/)
65
- assert.match(parse.error!.reason, /CAR-E-MANIFEST.*exact semver/)
66
- })
67
- })
68
-
69
- test('M6: peer 冲突链定位——validate FAIL 附 conflicts(文件/区间/实际版本/依赖路径)', async () => {
70
- await withTempDir('peer-conflict', async (dir) => {
71
- makePlugin(dir, 'dep-p.ts', { name: 'dep-p', version: '1.0.0' }, 't1')
72
- makePlugin(dir, 'consumer.ts', { name: 'consumer', version: '2.0.0', peers: [{ peer: 'dep-p', range: '^2.0.0' }] }, 't2')
73
- const { report } = await loadPlugins({ source: dir })
74
- assert.deepEqual(stageNames(report), [
75
- 'discover:PASS', 'verify:PASS', 'parse:PASS', 'validate:FAIL', 'topo:SKIPPED', 'register:SKIPPED',
76
- ])
77
- const conflicts = stageOf(report, 'validate').conflicts!
78
- assert.equal(conflicts.length, 1)
79
- const c = conflicts[0]!
80
- assert.match(c.file, /consumer\.ts$/)
81
- assert.equal(c.peer, 'dep-p')
82
- assert.equal(c.range, '^2.0.0')
83
- assert.equal(c.installed, '1.0.0')
84
- assert.equal(c.path, 'manifest.peers')
85
- })
86
- })
87
-
88
- test('M6: 警告——git 直载来源 CAR-W-GIT-DIRECT(E-03)+ peer 豁免 CAR-W-PEER-EXEMPT', async () => {
89
- await withTempDir('warnings', async (dir) => {
90
- // git 工作树直载:目录内含 .git 即产生告警(不失败)
91
- mkdirSync(join(dir, '.git'))
92
- makePlugin(dir, 'p.ts', { name: 'p', version: '1.0.0' }, 't')
93
- const { report: r1 } = await loadPlugins({ source: dir })
94
- assert.equal(stageOf(r1, 'discover').status, 'PASS')
95
- assert.ok(r1.warnings.some(w => w.startsWith('CAR-W-GIT-DIRECT')), 'git 直载告警')
96
- // git URL 来源同样告警
97
- const { report: r2 } = await loadPlugins({ source: dir, gitDirect: false })
98
- void r2
99
- const urlRes = await loadPlugins({ source: 'git+https://example.com/repo.git' })
100
- assert.ok(urlRes.report.warnings.some(w => w.startsWith('CAR-W-GIT-DIRECT')))
101
- // peer 豁免:relaxed + reason → 放行 + 告警留痕
102
- const dir2 = mkdtempSync(join(tmpdir(), 'car-s20-exempt-'))
103
- try {
104
- makePlugin(dir2, 'dep-p.ts', { name: 'dep-p', version: '1.0.0' }, 't1')
105
- makePlugin(dir2, 'consumer.ts', {
106
- name: 'consumer', version: '2.0.0',
107
- peers: [{ peer: 'dep-p', range: '^2.0.0' }],
108
- peerPolicyOverride: { relaxed: true, reason: '临时对齐中,下版本收紧' },
109
- })
110
- const { report } = await loadPlugins({ source: dir2 })
111
- assert.equal(stageOf(report, 'validate').status, 'PASS')
112
- assert.ok(report.warnings.some(w => w.startsWith('CAR-W-PEER-EXEMPT')))
113
- } finally { try { rmSync(dir2, { recursive: true, force: true }) } catch {} }
114
- })
115
- })
116
-
117
- test('M6: renderLoadReport 纯函数口径——plan + 阶段结果 → VO(规格签名)', () => {
118
- const startedAtMs = Date.now() - 5
119
- const plan = {
120
- installId: newInstallId(), source: '/x', startedTime: new Date(startedAtMs).toISOString(),
121
- startedAtMs, files: [], warnings: ['w1'],
122
- }
123
- const vo = renderLoadReport(plan, [{ stage: 'discover', status: 'PASS' }, { stage: 'parse', status: 'SKIPPED' }])
124
- assert.deepEqual(vo.warnings, ['w1'])
125
- assert.equal(vo.stages.length, 2)
126
- assert.ok(vo.durationMs >= 5)
127
- // 计划不可变:传入数组后续修改不泄漏进 VO(冻结语义)
128
- plan.warnings.push('w2')
129
- assert.deepEqual(vo.warnings, ['w1'])
130
- })
131
-
132
- // ==================== 热重载(SQ-05) ====================
133
-
134
- test('M6: 热重载——epoch 递增、旧句柄 invalidate 后报 CAR-INVALIDATED、新实例可用', async () => {
135
- await withTempDir('reload', async (dir) => {
136
- const file = join(dir, 'hot.ts')
137
- writeFileSync(file, `export const manifest = { name: 'hot', version: '1.0.0' }\n${PLUGIN_BODY('tool-v1')}`)
138
- const mgr = new ReloadManager()
139
- assert.equal(mgr.epoch, 0)
140
- const r1 = await mgr.reload(file)
141
- assert.equal(mgr.epoch, 1)
142
- assert.deepEqual(stageNames(r1.report), ['discover:PASS', 'verify:PASS', 'parse:PASS', 'validate:PASS', 'topo:PASS', 'register:PASS'])
143
- assert.equal(r1.plugins[0]!.manifest.version, '1.0.0')
144
- const host1: Array<{ name: string }> = []
145
- r1.plugins[0]!.bindCore({ registerTool: t => { host1.push(t) }, getRegisteredTools: () => host1 })
146
- assert.deepEqual(r1.plugins[0]!.api.getRegisteredTools().map(t => t.name), ['tool-v1'])
147
- // 重写源文件 → reload → epoch+1 击穿缓存,新代码生效
148
- writeFileSync(file, `export const manifest = { name: 'hot', version: '1.1.0' }\n${PLUGIN_BODY('tool-v2')}`)
149
- const r2 = await mgr.reload(file)
150
- assert.equal(mgr.epoch, 2)
151
- assert.equal(r2.plugins[0]!.manifest.version, '1.1.0')
152
- const host2: Array<{ name: string }> = []
153
- r2.plugins[0]!.bindCore({ registerTool: t => { host2.push(t) }, getRegisteredTools: () => host2 })
154
- assert.deepEqual(r2.plugins[0]!.api.getRegisteredTools().map(t => t.name), ['tool-v2'])
155
- // 旧句柄 invalidate 后访问显式报错(Stub 语义,防 stale context 误用)
156
- assert.throws(() => r1.plugins[0]!.api.getRegisteredTools(), /CAR-INVALIDATED/)
157
- // reload 报告留痕
158
- assert.equal(mgr.history.length, 2)
159
- })
160
- })
161
-
162
- test('M6: reload 失败路径——FAIL 报告含定位,管理器不静默回退', async () => {
163
- await withTempDir('reload-fail', async (dir) => {
164
- const file = join(dir, 'hot.ts')
165
- writeFileSync(file, `export const manifest = { name: 'hot', version: '1.0.0' }\n${PLUGIN_BODY('t')}`)
166
- const mgr = new ReloadManager()
167
- await mgr.reload(file)
168
- writeFileSync(file, `export const manifest = { name: 'hot', version: 'not-semver' }\n${PLUGIN_BODY('t')}`)
169
- const { report, plugins } = await mgr.reload(file)
170
- assert.equal(stageOf(report, 'parse').status, 'FAIL')
171
- assert.match(stageOf(report, 'parse').error!.reason, /exact semver/)
172
- assert.equal(plugins.length, 0, '失败重载不残留新实例')
173
- })
174
- })
175
-
176
- // ==================== car doctor(离线可过) ====================
177
-
178
- test('M6: doctor 凭据检查——只报存在性、不打印值;离线连通性 SKIPPED 不失败', async () => {
179
- const creds = doctorCredentials({ CAR_TOKEN: 'secret-value-should-never-appear' } as NodeJS.ProcessEnv)
180
- assert.equal(creds.length, 2)
181
- const carTok = creds.find(c => c.envVar === 'CAR_TOKEN')!
182
- assert.equal(carTok.present, true)
183
- const npmTok = creds.find(c => c.envVar === 'NPM_TOKEN')!
184
- assert.equal(npmTok.present, false)
185
- // 红线:凭据检查输出永不包含值
186
- const rendered = JSON.stringify(creds)
187
- assert.equal(rendered.includes('secret-value-should-never-appear'), false)
188
- // 显式跳过 = SKIPPED
189
- const skipped = await doctorConnectivity({ skip: true })
190
- assert.equal(skipped.status, 'SKIPPED')
191
- // 不可达 = SKIPPED 而非 FAIL(离线环境 doctor 整体 PASS 口径)
192
- const unreachable = await doctorConnectivity({ registryUrl: 'http://127.0.0.1:1/-/ping', timeoutMs: 800 })
193
- assert.equal(unreachable.status, 'SKIPPED')
194
- assert.match(unreachable.detail, /显式跳过|不可达/)
195
- })
196
-
197
- // ==================== QS-05 加载基线(P95 ≤ 800ms,纯本地 mock 插件) ====================
198
-
199
- test('QS-05: 加载耗时基线——P95 ≤ 800ms(20 轮热重载五插件,缓存击穿口径)', async () => {
200
- await withTempDir('p95', async (dir) => {
201
- const N = 5
202
- for (let i = 1; i <= N; i++) makePlugin(dir, `p${i}.ts`, { name: `p${i}`, version: '1.0.0' }, `tool-${i}`)
203
- const mgr = new ReloadManager()
204
- const runs = 20
205
- const durations: number[] = []
206
- for (let i = 0; i < runs; i++) {
207
- const { report } = await mgr.reload(dir)
208
- assert.deepEqual(stageNames(report), ['discover:PASS', 'verify:PASS', 'parse:PASS', 'validate:PASS', 'topo:PASS', 'register:PASS'])
209
- durations.push(report.durationMs)
210
- }
211
- durations.sort((a, b) => a - b)
212
- // 最近邻秩 P95(n=20 → 第 19 位,1-based)
213
- const p95 = durations[Math.ceil(0.95 * runs) - 1]!
214
- assert.ok(p95 <= 800, `P95=${p95}ms 应 ≤ 800ms(QS-05);全量=${JSON.stringify(durations)}`)
215
- })
216
- })
217
-
218
- // ==================== CLI 报告渲染(formatLoadReport 纯文本出口) ====================
219
-
220
- test('M6: formatLoadReport——PASS 摘要 + FAIL 定位 + 耗时/告警行', () => {
221
- const vo: LoadReportVO = {
222
- installId: 'car-install-x',
223
- startedTime: new Date(0).toISOString(),
224
- durationMs: 42,
225
- stages: [
226
- { stage: 'discover', status: 'PASS' },
227
- { stage: 'parse', status: 'FAIL', error: { file: '/a/b.ts', reason: 'CAR-E-MANIFEST: boom' } },
228
- { stage: 'validate', status: 'SKIPPED' },
229
- { stage: 'topo', status: 'SKIPPED' },
230
- { stage: 'register', status: 'SKIPPED' },
231
- ],
232
- warnings: ['CAR-W-GIT-DIRECT: demo'],
233
- }
234
- const lines = formatLoadReport(vo)
235
- const all = lines.join('\n')
236
- assert.match(all, /installId=car-install-x/)
237
- assert.match(all, /\[parse\s*\] FAIL — CAR-E-MANIFEST: boom \(file: \/a\/b\.ts\)/)
238
- assert.match(all, /\[validate\] SKIPPED/)
239
- assert.match(all, /durationMs=42 warnings=1/)
240
- assert.match(all, /CAR-W-GIT-DIRECT: demo/)
241
- })