@modusensus/dsh-mneme 0.13.0 → 0.14.0-beta.2

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 (543) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +830 -828
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +964 -964
  5. package/bin/dsh-mneme-choro.mjs +262 -254
  6. package/bin/dsh-mneme-mcp.mjs +510 -510
  7. package/bin/dsh-mneme-serve.mjs +151 -151
  8. package/cordis.patch.yml +21 -21
  9. package/docs/CONFIGURATION.md +407 -402
  10. package/docs/CONTINUITY.md +139 -139
  11. package/docs/DAEMON.md +57 -57
  12. package/docs/DIRECTORIES.md +114 -114
  13. package/docs/DISTILL.md +397 -371
  14. package/docs/DSH-GO.md +97 -97
  15. package/docs/ENTITIES.md +247 -247
  16. package/docs/FORGET.md +77 -77
  17. package/docs/KB.md +110 -110
  18. package/docs/LOCAL_MODEL.md +222 -222
  19. package/docs/SEMANTIC.md +256 -256
  20. package/docs/SHARE.md +552 -552
  21. package/docs/SLEEP.md +202 -202
  22. package/docs/STORAGE.md +122 -122
  23. package/docs/WORKFLOWS.md +366 -366
  24. package/lib/api-standalone.js +1007 -1007
  25. package/lib/api.js +3600 -3394
  26. package/lib/attachment-text.js +122 -122
  27. package/lib/bootstrap-http.js +105 -105
  28. package/lib/bootstrap.js +215 -215
  29. package/lib/candidates.js +454 -454
  30. package/lib/capabilities.js +819 -819
  31. package/lib/choro-command.js +111 -111
  32. package/lib/client.js +14420 -13436
  33. package/lib/commands.js +95 -95
  34. package/lib/config-keys.js +144 -144
  35. package/lib/config.js +1361 -1317
  36. package/lib/content-hash.js +54 -54
  37. package/lib/continuity.js +258 -258
  38. package/lib/date-hint.js +32 -0
  39. package/lib/diary/auto.js +190 -190
  40. package/lib/diary/compose.js +440 -440
  41. package/lib/diary/dir.js +59 -59
  42. package/lib/diary/export.js +97 -97
  43. package/lib/diary/index.js +26 -26
  44. package/lib/diary/library.js +189 -189
  45. package/lib/diary/qq-inbox.js +142 -142
  46. package/lib/diary/rollover.js +61 -61
  47. package/lib/diary/session-provider.js +122 -122
  48. package/lib/document-index.js +94 -94
  49. package/lib/document.js +322 -322
  50. package/lib/dream/clustering.js +123 -123
  51. package/lib/dream/decisions.js +945 -907
  52. package/lib/dream/narratives.js +44 -44
  53. package/lib/dream/sleep.js +2027 -1887
  54. package/lib/dream/triage.js +139 -0
  55. package/lib/dream.js +1879 -1764
  56. package/lib/dsh-go/host.js +162 -162
  57. package/lib/dsh-go/panel.js +2091 -2091
  58. package/lib/dsh-go/text.js +342 -342
  59. package/lib/dsh-go/views/common.js +68 -68
  60. package/lib/dsh-go/views/entity.js +48 -48
  61. package/lib/dsh-go/views/memory.js +198 -198
  62. package/lib/dsh-go/views/root.js +30 -30
  63. package/lib/dsh-go/views/settings.js +398 -398
  64. package/lib/dsh-go/views/status.js +51 -51
  65. package/lib/embedding.js +342 -342
  66. package/lib/entities/extractor.js +285 -285
  67. package/lib/fence.js +24 -24
  68. package/lib/graph/anchoring.js +184 -184
  69. package/lib/guide.js +74 -74
  70. package/lib/heat.js +144 -144
  71. package/lib/hot-memory.js +119 -107
  72. package/lib/image-describe.js +158 -0
  73. package/lib/index.js +852 -822
  74. package/lib/inject.js +656 -639
  75. package/lib/kb/index.js +309 -309
  76. package/lib/kb/routes.js +219 -219
  77. package/lib/kb/tools.js +148 -148
  78. package/lib/lang.js +607 -584
  79. package/lib/local-embedder.js +358 -358
  80. package/lib/maintenance.js +162 -162
  81. package/lib/memory-source.js +9 -9
  82. package/lib/mirror.js +330 -330
  83. package/lib/organize.js +456 -456
  84. package/lib/peak-hours.js +172 -172
  85. package/lib/pipeline-activities.js +178 -178
  86. package/lib/pipeline-attribution.js +165 -165
  87. package/lib/pipeline-command.js +279 -279
  88. package/lib/pipeline-confirm.js +142 -142
  89. package/lib/pipeline-exec.js +174 -174
  90. package/lib/pipeline-nudge-auto.js +129 -129
  91. package/lib/pipeline-nudge.js +1623 -1623
  92. package/lib/pipeline-observations.js +181 -181
  93. package/lib/pipeline-observe.js +133 -133
  94. package/lib/pipeline-runner.js +361 -361
  95. package/lib/pipeline-runs.js +442 -442
  96. package/lib/pipeline.js +716 -716
  97. package/lib/placement.js +13 -13
  98. package/lib/pre-inject-gate.js +354 -354
  99. package/lib/profile-extract.js +164 -164
  100. package/lib/profile-facts.js +356 -356
  101. package/lib/provenance-http.js +251 -251
  102. package/lib/quality-filter.js +162 -162
  103. package/lib/recall-stats.js +190 -190
  104. package/lib/reclaim-http.js +150 -150
  105. package/lib/reranker.js +269 -269
  106. package/lib/runtime/adopt.js +176 -176
  107. package/lib/runtime/closure.js +172 -172
  108. package/lib/runtime/download.js +247 -247
  109. package/lib/runtime/layout.js +265 -265
  110. package/lib/runtime/loader.js +252 -252
  111. package/lib/runtime/model-fetch.js +361 -361
  112. package/lib/runtime/provision.js +237 -237
  113. package/lib/runtime/tarball.js +130 -130
  114. package/lib/runtime/verify.js +223 -223
  115. package/lib/safe-slice.js +18 -18
  116. package/lib/scope-state.js +277 -277
  117. package/lib/scope.js +187 -187
  118. package/lib/search/adaptive.js +60 -60
  119. package/lib/search/bm25.js +96 -96
  120. package/lib/semantic.js +572 -572
  121. package/lib/sensitive-scan.js +209 -209
  122. package/lib/serve.js +228 -228
  123. package/lib/service.js +3268 -3196
  124. package/lib/session-gate.js +91 -91
  125. package/lib/session-usage.js +350 -350
  126. package/lib/settings.js +827 -802
  127. package/lib/share-http.js +281 -281
  128. package/lib/share-import.js +234 -234
  129. package/lib/share.js +335 -335
  130. package/lib/store.js +5895 -5662
  131. package/lib/summarize.js +1388 -1343
  132. package/lib/tools.js +1370 -1356
  133. package/lib/user-settings.js +230 -230
  134. package/lib/vector-index.js +180 -180
  135. package/lib/version-check.js +122 -122
  136. package/lib/wiki/claims.js +267 -0
  137. package/lib/wiki/cli-hint.js +90 -90
  138. package/lib/wiki/inject.js +164 -164
  139. package/lib/wiki/partition.js +200 -200
  140. package/lib/wiki/plan.js +214 -214
  141. package/lib/wiki/queries.js +640 -474
  142. package/lib/wiki/render.js +261 -187
  143. package/lib/wiki/scan.js +371 -371
  144. package/lib/wiki/t0.js +500 -463
  145. package/lib/wiki/t1-jobs.js +232 -232
  146. package/lib/wiki/t1.js +609 -502
  147. package/lib/write-admission.js +404 -404
  148. package/locale/en.json +6 -6
  149. package/locale/zh.json +6 -6
  150. package/package.json +15 -1
  151. package/runtime-manifest.json +526 -526
  152. package/scripts/benchmark-embed.js +201 -201
  153. package/scripts/benchmark-recall.js +249 -249
  154. package/scripts/benchmark-rerank.js +166 -166
  155. package/scripts/build-runtime-manifest.mjs +224 -224
  156. package/scripts/changelog-prep.mjs +91 -91
  157. package/scripts/check-sync.js +44 -44
  158. package/scripts/e2e-dsh.js +220 -220
  159. package/scripts/mneme-runtime.mjs +277 -277
  160. package/scripts/publish-gate.mjs +197 -197
  161. package/scripts/release-docs.mjs +210 -204
  162. package/scripts/release-notes.mjs +164 -161
  163. package/scripts/stress-dsh.js +255 -255
  164. package/scripts/sync-lib.js +47 -47
  165. package/scripts/sync-test-badge.mjs +69 -69
  166. package/scripts/test-count-sync.mjs +48 -48
  167. package/src/api-standalone.js +1007 -1007
  168. package/src/api.js +3600 -3394
  169. package/src/attachment-text.js +122 -122
  170. package/src/bootstrap-http.js +105 -105
  171. package/src/bootstrap.js +215 -215
  172. package/src/candidates.js +454 -454
  173. package/src/capabilities.js +819 -819
  174. package/src/choro-command.js +111 -111
  175. package/src/commands.js +95 -95
  176. package/src/config-keys.js +144 -144
  177. package/src/config.js +1361 -1317
  178. package/src/content-hash.js +54 -54
  179. package/src/continuity.js +258 -258
  180. package/src/date-hint.js +32 -0
  181. package/src/diary/auto.js +190 -190
  182. package/src/diary/compose.js +440 -440
  183. package/src/diary/dir.js +59 -59
  184. package/src/diary/export.js +97 -97
  185. package/src/diary/index.js +26 -26
  186. package/src/diary/library.js +189 -189
  187. package/src/diary/qq-inbox.js +142 -142
  188. package/src/diary/rollover.js +61 -61
  189. package/src/diary/session-provider.js +122 -122
  190. package/src/document-index.js +94 -94
  191. package/src/document.js +322 -322
  192. package/src/dream/clustering.js +123 -123
  193. package/src/dream/decisions.js +945 -907
  194. package/src/dream/narratives.js +44 -44
  195. package/src/dream/sleep.js +2027 -1887
  196. package/src/dream/triage.js +139 -0
  197. package/src/dream.js +1879 -1764
  198. package/src/dsh-go/host.js +162 -162
  199. package/src/dsh-go/panel.js +2091 -2091
  200. package/src/dsh-go/text.js +342 -342
  201. package/src/dsh-go/views/common.js +68 -68
  202. package/src/dsh-go/views/entity.js +48 -48
  203. package/src/dsh-go/views/memory.js +198 -198
  204. package/src/dsh-go/views/root.js +30 -30
  205. package/src/dsh-go/views/settings.js +398 -398
  206. package/src/dsh-go/views/status.js +51 -51
  207. package/src/embedding.js +342 -342
  208. package/src/entities/extractor.js +285 -285
  209. package/src/fence.js +24 -24
  210. package/src/graph/anchoring.js +184 -184
  211. package/src/guide.js +74 -74
  212. package/src/heat.js +144 -144
  213. package/src/hot-memory.js +119 -107
  214. package/src/image-describe.js +158 -0
  215. package/src/index.js +852 -822
  216. package/src/inject.js +656 -639
  217. package/src/kb/index.js +309 -309
  218. package/src/kb/routes.js +219 -219
  219. package/src/kb/tools.js +148 -148
  220. package/src/lang.js +607 -584
  221. package/src/local-embedder.js +358 -358
  222. package/src/maintenance.js +162 -162
  223. package/src/memory-source.js +9 -9
  224. package/src/mirror.js +330 -330
  225. package/src/organize.js +456 -456
  226. package/src/peak-hours.js +172 -172
  227. package/src/pipeline-activities.js +178 -178
  228. package/src/pipeline-attribution.js +165 -165
  229. package/src/pipeline-command.js +279 -279
  230. package/src/pipeline-confirm.js +142 -142
  231. package/src/pipeline-exec.js +174 -174
  232. package/src/pipeline-nudge-auto.js +129 -129
  233. package/src/pipeline-nudge.js +1623 -1623
  234. package/src/pipeline-observations.js +181 -181
  235. package/src/pipeline-observe.js +133 -133
  236. package/src/pipeline-runner.js +361 -361
  237. package/src/pipeline-runs.js +442 -442
  238. package/src/pipeline.js +716 -716
  239. package/src/placement.js +13 -13
  240. package/src/pre-inject-gate.js +354 -354
  241. package/src/profile-extract.js +164 -164
  242. package/src/profile-facts.js +356 -356
  243. package/src/provenance-http.js +251 -251
  244. package/src/quality-filter.js +162 -162
  245. package/src/recall-stats.js +190 -190
  246. package/src/reclaim-http.js +150 -150
  247. package/src/reranker.js +269 -269
  248. package/src/runtime/adopt.js +176 -176
  249. package/src/runtime/closure.js +172 -172
  250. package/src/runtime/download.js +247 -247
  251. package/src/runtime/layout.js +265 -265
  252. package/src/runtime/loader.js +252 -252
  253. package/src/runtime/model-fetch.js +361 -361
  254. package/src/runtime/provision.js +237 -237
  255. package/src/runtime/tarball.js +130 -130
  256. package/src/runtime/verify.js +223 -223
  257. package/src/safe-slice.js +18 -18
  258. package/src/scope-state.js +277 -277
  259. package/src/scope.js +187 -187
  260. package/src/search/adaptive.js +60 -60
  261. package/src/search/bm25.js +96 -96
  262. package/src/semantic.js +572 -572
  263. package/src/sensitive-scan.js +209 -209
  264. package/src/serve.js +228 -228
  265. package/src/service.js +3268 -3196
  266. package/src/session-gate.js +91 -91
  267. package/src/session-usage.js +350 -350
  268. package/src/settings.js +827 -802
  269. package/src/share-http.js +281 -281
  270. package/src/share-import.js +234 -234
  271. package/src/share.js +335 -335
  272. package/src/store.js +5895 -5662
  273. package/src/summarize.js +1388 -1343
  274. package/src/tools.js +1370 -1356
  275. package/src/user-settings.js +230 -230
  276. package/src/vector-index.js +180 -180
  277. package/src/version-check.js +122 -122
  278. package/src/wiki/claims.js +267 -0
  279. package/src/wiki/cli-hint.js +90 -90
  280. package/src/wiki/inject.js +164 -164
  281. package/src/wiki/partition.js +200 -200
  282. package/src/wiki/plan.js +214 -214
  283. package/src/wiki/queries.js +640 -474
  284. package/src/wiki/render.js +261 -187
  285. package/src/wiki/scan.js +371 -371
  286. package/src/wiki/t0.js +500 -463
  287. package/src/wiki/t1-jobs.js +232 -232
  288. package/src/wiki/t1.js +609 -502
  289. package/src/write-admission.js +404 -404
  290. package/test/adaptive-inject-budget.test.js +85 -85
  291. package/test/api-hardening.test.js +173 -173
  292. package/test/api-nudge-route.test.js +259 -259
  293. package/test/api-routes-gaps.test.js +1092 -1092
  294. package/test/api.test.js +3007 -2988
  295. package/test/attachment-text.test.js +139 -139
  296. package/test/audit.test.js +448 -448
  297. package/test/baseline-persist.test.js +248 -248
  298. package/test/benchmark.test.js +94 -94
  299. package/test/bm25-corpus-cache.test.js +145 -145
  300. package/test/bootstrap.test.js +228 -228
  301. package/test/candidates.test.js +345 -345
  302. package/test/capabilities.test.js +577 -577
  303. package/test/capacity-warning.test.js +262 -262
  304. package/test/changelog-prep.test.js +125 -125
  305. package/test/choro-command.test.js +218 -218
  306. package/test/cli-enums.test.js +37 -37
  307. package/test/cli-flag-value.test.js +86 -86
  308. package/test/cli-receive.test.js +390 -390
  309. package/test/cli-share.test.js +437 -437
  310. package/test/client-share.test.js +225 -225
  311. package/test/client.test.js +4595 -4184
  312. package/test/clustering.test.js +100 -100
  313. package/test/commands.test.js +106 -106
  314. package/test/config-keys-routes.test.js +233 -233
  315. package/test/config.test.js +153 -153
  316. package/test/conflict-freeze.test.js +344 -344
  317. package/test/conflict-ledger-enforce.test.js +452 -0
  318. package/test/conflict-ledger.test.js +242 -0
  319. package/test/conflict-reviewed.test.js +79 -79
  320. package/test/content-hash.test.js +204 -204
  321. package/test/continuity.test.js +452 -452
  322. package/test/contrast.test.js +432 -320
  323. package/test/date-hint.test.js +156 -0
  324. package/test/dedupe-candidate-window.test.js +180 -180
  325. package/test/dependency-overrides.test.js +78 -78
  326. package/test/diary-library.test.js +187 -187
  327. package/test/diary.test.js +576 -576
  328. package/test/differentiate-feedback.test.js +248 -248
  329. package/test/distill-tx-embed-gap.test.js +110 -110
  330. package/test/docs-config-keys.test.js +65 -65
  331. package/test/document-dir.test.js +233 -233
  332. package/test/document.test.js +703 -703
  333. package/test/dream-archive-guardrail.test.js +116 -116
  334. package/test/dream-baseline-clamp.test.js +174 -0
  335. package/test/dream-candidate-hybrid.test.js +281 -281
  336. package/test/dream-coverage-degrade.test.js +86 -86
  337. package/test/dream-decision-evidence.test.js +314 -314
  338. package/test/dream-failure-backoff.test.js +173 -173
  339. package/test/dream-fence.test.js +207 -207
  340. package/test/dream-maxtokens-fallback.test.js +132 -132
  341. package/test/dream-merge-guardrail.test.js +219 -219
  342. package/test/dream-peak-hours.test.js +194 -194
  343. package/test/dream.test.js +1528 -1528
  344. package/test/dsh-go-host.test.js +134 -134
  345. package/test/dsh-go-panel-actions.test.js +854 -854
  346. package/test/dsh-go-panel.test.js +251 -251
  347. package/test/embed-ready-poll.test.js +94 -94
  348. package/test/embed-text-reasons.test.js +112 -112
  349. package/test/embedding-cache-f32.test.js +276 -276
  350. package/test/embedding-ready.test.js +226 -226
  351. package/test/entities.test.js +711 -711
  352. package/test/entity-recall.test.js +179 -179
  353. package/test/epistemic.test.js +325 -325
  354. package/test/failure-query.test.js +147 -147
  355. package/test/failure-retention.test.js +105 -105
  356. package/test/fnew-0112.test.js +311 -311
  357. package/test/fnew-03.test.js +422 -422
  358. package/test/forget-corrections.test.js +70 -70
  359. package/test/graph-anchoring.test.js +184 -184
  360. package/test/graph-api.test.js +333 -209
  361. package/test/graph-boosted-relations.test.js +189 -189
  362. package/test/graph-inject-budget.test.js +103 -103
  363. package/test/graph-multipath.test.js +269 -269
  364. package/test/graph-passive-confirm.test.js +91 -91
  365. package/test/graph-weight-evolution.test.js +104 -104
  366. package/test/heat.test.js +175 -175
  367. package/test/helpers/dream-mock.js +86 -86
  368. package/test/helpers/peer-worker.mjs +25 -25
  369. package/test/helpers/runtime-source.js +60 -60
  370. package/test/helpers/tar-builder.js +80 -80
  371. package/test/helpers/write-admission-samples.js +312 -312
  372. package/test/hot-memory.test.js +259 -211
  373. package/test/image-description.test.js +271 -0
  374. package/test/image-memory.test.js +170 -170
  375. package/test/inject-content-cap.test.js +91 -91
  376. package/test/inject-first-round-bm25.test.js +89 -89
  377. package/test/inject-heat.test.js +77 -77
  378. package/test/inject-parent-gate.test.js +134 -106
  379. package/test/inject-pin-explicit.test.js +300 -300
  380. package/test/inject-pin-vector-crowd.test.js +225 -225
  381. package/test/inject-pools.test.js +195 -195
  382. package/test/inject-recall.test.js +172 -172
  383. package/test/inject-rotation.test.js +161 -161
  384. package/test/inject.test.js +553 -494
  385. package/test/issue-617-step1-query-and-fusion.test.js +361 -361
  386. package/test/kb-api-wiring.test.js +128 -128
  387. package/test/kb-collections.test.js +272 -272
  388. package/test/kb-routes.test.js +379 -379
  389. package/test/lang.test.js +98 -98
  390. package/test/lastrunat-persist.test.js +101 -101
  391. package/test/lib-smoke.test.js +116 -116
  392. package/test/llm-audit.test.js +815 -815
  393. package/test/llm-message-source.test.js +207 -207
  394. package/test/local-embedder.test.js +292 -292
  395. package/test/maintenance.test.js +501 -501
  396. package/test/mcp-stdio.test.js +385 -385
  397. package/test/mint-source.test.js +311 -311
  398. package/test/mirror-dirty.test.js +424 -424
  399. package/test/mirror-edit-digest.test.js +187 -187
  400. package/test/mirror-generation.test.js +499 -499
  401. package/test/mirror-types.test.js +208 -208
  402. package/test/mirror.test.js +249 -249
  403. package/test/narrative-bars.test.js +162 -162
  404. package/test/organize-tool.test.js +232 -232
  405. package/test/organize.test.js +418 -418
  406. package/test/panel-chart-scale.test.js +90 -0
  407. package/test/panel-tokens.test.js +245 -0
  408. package/test/peak-hours-observability.test.js +70 -70
  409. package/test/peer-blockers.test.js +193 -193
  410. package/test/persona-addressing.test.js +83 -83
  411. package/test/persona-api.test.js +172 -172
  412. package/test/persona-by-session.test.js +568 -568
  413. package/test/persona-flag.test.js +36 -36
  414. package/test/persona-inject.test.js +145 -145
  415. package/test/persona-settings.test.js +198 -198
  416. package/test/pipeline-activities.test.js +155 -155
  417. package/test/pipeline-attribution.test.js +175 -175
  418. package/test/pipeline-command.test.js +243 -243
  419. package/test/pipeline-confirm.test.js +148 -148
  420. package/test/pipeline-exec.test.js +172 -172
  421. package/test/pipeline-nudge-auto.test.js +248 -248
  422. package/test/pipeline-nudge.test.js +1278 -1278
  423. package/test/pipeline-observations.test.js +157 -157
  424. package/test/pipeline-observe.test.js +127 -127
  425. package/test/pipeline-r1r2.test.js +278 -278
  426. package/test/pipeline-runner.test.js +468 -468
  427. package/test/pipeline-runs.test.js +767 -767
  428. package/test/pipeline.test.js +636 -636
  429. package/test/placement.test.js +36 -36
  430. package/test/policy-epoch.test.js +259 -259
  431. package/test/pre-inject-gate.test.js +444 -444
  432. package/test/profile-extract.test.js +269 -269
  433. package/test/profile-facts.test.js +492 -492
  434. package/test/prompt-stance.test.js +128 -128
  435. package/test/provenance-http.test.js +367 -367
  436. package/test/publish-gate.test.js +272 -272
  437. package/test/quality-backfill.test.js +290 -290
  438. package/test/quality-filter.test.js +295 -295
  439. package/test/readme-claims.test.js +113 -113
  440. package/test/reasoning-effort.test.js +752 -752
  441. package/test/recall-anti-update.test.js +82 -82
  442. package/test/recall-evals.test.js +253 -253
  443. package/test/recall-layer.test.js +326 -326
  444. package/test/recall-retention.test.js +128 -128
  445. package/test/recall-stats.test.js +274 -274
  446. package/test/receipt-chain.test.js +451 -451
  447. package/test/reclaim-http.test.js +209 -209
  448. package/test/reflection-update-gate.test.js +129 -129
  449. package/test/reflection.test.js +226 -226
  450. package/test/reindex-backfill.test.js +475 -475
  451. package/test/reindex-scheduler.test.js +369 -369
  452. package/test/release-docs.test.js +156 -136
  453. package/test/release-notes.test.js +265 -240
  454. package/test/release-workflow.test.js +93 -93
  455. package/test/reranker-single-logit.test.js +73 -73
  456. package/test/reranker.test.js +197 -197
  457. package/test/resident-status.test.js +83 -83
  458. package/test/route-policy.test.js +113 -113
  459. package/test/runtime-adopt.test.js +206 -206
  460. package/test/runtime-cli.test.js +89 -89
  461. package/test/runtime-closure.test.js +193 -193
  462. package/test/runtime-download.test.js +302 -302
  463. package/test/runtime-layout.test.js +163 -163
  464. package/test/runtime-loader.test.js +255 -255
  465. package/test/runtime-manifest.test.js +159 -159
  466. package/test/runtime-model-fetch.test.js +348 -348
  467. package/test/runtime-provision.test.js +101 -101
  468. package/test/runtime-tarball.test.js +81 -81
  469. package/test/runtime-verify.test.js +319 -319
  470. package/test/scope-candidates.test.js +491 -491
  471. package/test/scope-distill-stamp.test.js +134 -134
  472. package/test/scope-explicit.test.js +452 -452
  473. package/test/scope-inject-soft.test.js +106 -106
  474. package/test/scope-optional-service.test.js +110 -110
  475. package/test/scope-retrieval.test.js +197 -197
  476. package/test/scope-state.test.js +610 -610
  477. package/test/scope-storage.test.js +255 -255
  478. package/test/scope-strict.test.js +367 -367
  479. package/test/search-fusion.test.js +90 -90
  480. package/test/security-doc.test.js +188 -178
  481. package/test/semantic.test.js +124 -124
  482. package/test/sensitive-scan.test.js +251 -251
  483. package/test/serve-bin.test.js +121 -121
  484. package/test/serve.test.js +258 -258
  485. package/test/service-search.test.js +174 -174
  486. package/test/service.test.js +410 -410
  487. package/test/session-gate.test.js +264 -264
  488. package/test/session-usage.test.js +317 -317
  489. package/test/settings.test.js +273 -273
  490. package/test/share-foreign-rows.test.js +144 -144
  491. package/test/share-http.test.js +144 -144
  492. package/test/share-import.test.js +577 -577
  493. package/test/share-imported-guard.test.js +311 -311
  494. package/test/share-imported-identity.test.js +111 -111
  495. package/test/share-pack.test.js +511 -511
  496. package/test/share-receive-http.test.js +756 -756
  497. package/test/share-routes.test.js +337 -337
  498. package/test/share-store.test.js +133 -133
  499. package/test/share-validate.test.js +101 -101
  500. package/test/skip-invalid-restore.test.js +262 -262
  501. package/test/sleep-action-set.test.js +441 -441
  502. package/test/sleep-catchup.test.js +315 -315
  503. package/test/sleep-failure-backoff.test.js +156 -156
  504. package/test/sleep-heat.test.js +174 -174
  505. package/test/sleep-scheduler-rearm.test.js +89 -89
  506. package/test/sleep.test.js +1455 -1418
  507. package/test/standalone-api.test.js +1344 -1344
  508. package/test/store-vector-cache.test.js +98 -98
  509. package/test/store-write-path-columns.test.js +191 -191
  510. package/test/store.test.js +744 -744
  511. package/test/stress.test.js +209 -209
  512. package/test/summarize-occurred-at.test.js +127 -127
  513. package/test/summarize-parse-diagnostics.test.js +75 -75
  514. package/test/summarize-reasoning-effort.test.js +189 -189
  515. package/test/summarize.test.js +2251 -2251
  516. package/test/supersede-state-tracking.test.js +394 -394
  517. package/test/test-count-sync.test.js +71 -71
  518. package/test/tool-state-ignore.test.js +65 -65
  519. package/test/tools-dto-schema-matrix.test.js +398 -398
  520. package/test/tools-memory-get-schema.test.js +65 -65
  521. package/test/tools.test.js +585 -585
  522. package/test/updated-at-semantics.test.js +113 -113
  523. package/test/user-settings-preview.test.js +127 -127
  524. package/test/user-settings-segments.test.js +527 -527
  525. package/test/vector-index.test.js +334 -334
  526. package/test/version-check.test.js +152 -152
  527. package/test/webServer-optional.test.js +82 -82
  528. package/test/wiki-api.test.js +734 -627
  529. package/test/wiki-claims.test.js +194 -0
  530. package/test/wiki-cli-hint.test.js +118 -118
  531. package/test/wiki-inject.test.js +265 -265
  532. package/test/wiki-partition.test.js +112 -112
  533. package/test/wiki-plan.test.js +80 -80
  534. package/test/wiki-provenance.test.js +202 -0
  535. package/test/wiki-queries.test.js +398 -277
  536. package/test/wiki-scan.test.js +208 -208
  537. package/test/wiki-store.test.js +190 -190
  538. package/test/wiki-t0.test.js +456 -380
  539. package/test/wiki-t1-jobs.test.js +125 -125
  540. package/test/wiki-t1.test.js +460 -235
  541. package/test/wiki-tools.test.js +176 -176
  542. package/test/workflow-guards.test.js +155 -155
  543. package/test/write-admission.test.js +668 -668
@@ -1,628 +1,735 @@
1
- // chorography 四条读路由的回归锁(M1)。锁四层闸的语义分叉(#497 口径):
2
- // choro queries 缺装配 → 503 unavailable(环境缺件);chorographyEnabled 关 →
3
- // 409 disabled(用户没开);repo 解析失败 → 404 / 歧义 → 400 带可选清单;
4
- // 快乐路径四条全通。数据用真实 T0 fixture 建(同 wiki-queries 的对账纪律)。
5
- import test from "node:test";
6
- import assert from "node:assert/strict";
7
- import { EventEmitter } from "node:events";
8
- import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { join } from "node:path";
11
- import { execFileSync } from "node:child_process";
12
- import { createStore } from "../src/store.js";
13
- import { createService } from "../src/service.js";
14
- import { createApi } from "../src/api.js";
15
- import { createSettings } from "../src/settings.js";
16
- import { createChoroQueries } from "../src/wiki/queries.js";
17
- import { runChoroT0 } from "../src/wiki/t0.js";
18
-
19
- class FakeRes extends EventEmitter {
20
- constructor() { super(); this.statusCode = 200; this.body = ""; }
21
- writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
22
- end(text) { this.body = text ?? ""; this.emit("end"); return this; }
23
- }
24
-
25
- function req(path, method = "GET") {
26
- const r = new EventEmitter();
27
- r.url = path;
28
- r.method = method;
29
- r.headers = {};
30
- return r;
31
- }
32
-
33
- function setup({ config = null, choro = null } = {}) {
34
- const store = createStore(":memory:");
35
- const service = createService({ store, mirror: null, config: {} });
36
- const settings = createSettings(store.db);
37
- const routes = [];
38
- const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
39
- createApi(ctx, service, settings, { add: () => {}, remove: () => false, list: () => [] }, null, undefined, "", config, null, null, choro);
40
- return { routes };
41
- }
42
-
43
- function handler(routes, path) {
44
- return routes.find((r) => r.path === path);
45
- }
46
-
47
- async function invoke(route, path) {
48
- const res = new FakeRes();
49
- await route.handler(req(path), res);
50
- return { status: res.statusCode, body: JSON.parse(res.body || "{}") };
51
- }
52
-
53
- // POST 版:带 JSON body(readBody 走 data/end 事件)
54
- async function invokePost(route, path, body) {
55
- const r = req(path, "POST");
56
- process.nextTick(() => {
57
- r.emit("data", Buffer.from(JSON.stringify(body ?? {})));
58
- r.emit("end");
59
- });
60
- const res = new FakeRes();
61
- await route.handler(r, res);
62
- return { status: res.statusCode, body: JSON.parse(res.body || "{}") };
63
- }
64
-
65
- function makeRepo(store) {
66
- const root = mkdtempSync(join(tmpdir(), "choro-api-"));
67
- writeFileSync(join(root, "package.json"), JSON.stringify({ name: "apifix", scripts: { test: "node --test" } }, null, 2) + "\n", "utf8");
68
- mkdirSync(join(root, "src"), { recursive: true });
69
- writeFileSync(join(root, "src", "a.js"), 'import { b } from "./b.js";\nexport const a = () => b();\n', "utf8");
70
- writeFileSync(join(root, "src", "b.js"), "export const b = () => 1;\n", "utf8");
71
- execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
72
- execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
73
- execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
74
- runChoroT0({ repoRoot: root, store, apply: true, now: () => "T" });
75
- return root;
76
- }
77
-
78
- function rmTempBestEffort(dir) {
79
- try {
80
- rmSync(dir, { recursive: true, force: true });
81
- } catch {
82
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
83
- try { rmSync(dir, { recursive: true, force: true }); } catch { /* 留给系统清理 */ }
84
- }
85
- }
86
-
87
- test("四条 choro 路由都在册且 policy 全 open", () => {
88
- const { routes } = setup({});
89
- for (const p of ["/api/dsh-mneme/choro/repos", "/api/dsh-mneme/choro/modules", "/api/dsh-mneme/choro/page", "/api/dsh-mneme/choro/search"]) {
90
- const route = handler(routes, p);
91
- assert.ok(route, `${p} 在册`);
92
- assert.equal(route.policy, "open");
93
- }
94
- });
95
-
96
- test("queries 缺装配 → 503;开关关 → 409 disabled(语义分叉)", async () => {
97
- const missing = setup({});
98
- const reposRoute = handler(missing.routes, "/api/dsh-mneme/choro/repos");
99
- const a = await invoke(reposRoute, "/api/dsh-mneme/choro/repos");
100
- assert.equal(a.status, 503);
101
- assert.equal(a.body.error, "unavailable");
102
-
103
- const off = setup({ config: { chorographyEnabled: false }, choro: createChoroQueries({ store: createStore(":memory:") }) });
104
- const b = await invoke(handler(off.routes, "/api/dsh-mneme/choro/repos"), "/api/dsh-mneme/choro/repos");
105
- assert.equal(b.status, 409);
106
- assert.equal(b.body.error, "disabled");
107
- });
108
-
109
- test("快乐路径:repos / modules / page / search 全通", async () => {
110
- const store = createStore(":memory:");
111
- const root = makeRepo(store);
112
- try {
113
- const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
114
- const repos = await invoke(handler(routes, "/api/dsh-mneme/choro/repos"), "/api/dsh-mneme/choro/repos");
115
- assert.equal(repos.status, 200);
116
- assert.equal(repos.body.repos.length, 1);
117
- const repoId = repos.body.repos[0].id;
118
-
119
- const modules = await invoke(handler(routes, "/api/dsh-mneme/choro/modules"), `/api/dsh-mneme/choro/modules?repo=${repoId}`);
120
- assert.equal(modules.status, 200);
121
- assert.ok(modules.body.modules.length >= 2);
122
- assert.ok(Array.isArray(modules.body.edges));
123
- // 面板的地志页导入要把「根路径 + 页相对路径」拼成磁盘绝对路径(契约 §3.6),
124
- // 回执里缺 relPath 时它会静默回落到「请先选模块」——这条锁的就是那个真机 bug。
125
- const withPage = modules.body.modules.filter((m) => m.page_rel_path);
126
- assert.ok(withPage.length >= 1, "fixture 里至少一个模块有骨架页");
127
- for (const m of withPage) assert.equal(m.relPath, m.page_rel_path);
128
-
129
- const page = await invoke(handler(routes, "/api/dsh-mneme/choro/page"), `/api/dsh-mneme/choro/page?repo=${repoId}&slug=src`);
130
- assert.equal(page.status, 200);
131
- assert.ok(page.body.page.content.includes("## 文件清单"));
132
-
133
- const search = await invoke(handler(routes, "/api/dsh-mneme/choro/search"), `/api/dsh-mneme/choro/search?repo=${repoId}&q=b.js`);
134
- assert.equal(search.status, 200);
135
- assert.ok(search.body.hits.length >= 1);
136
- } finally {
137
- rmTempBestEffort(root);
138
- }
139
- });
140
-
141
- test("repo 解析失败各归各位:未知 → 404;歧义 → 400 带清单;slug 缺 → 400", async () => {
142
- const store = createStore(":memory:");
143
- const root = makeRepo(store);
144
- try {
145
- const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
146
- const notFound = await invoke(handler(routes, "/api/dsh-mneme/choro/modules"), "/api/dsh-mneme/choro/modules?repo=nope");
147
- assert.equal(notFound.status, 404);
148
- assert.equal(notFound.body.error, "repo-not-found");
149
-
150
- const root2 = makeRepo(store);
151
- try {
152
- const ambiguous = await invoke(handler(routes, "/api/dsh-mneme/choro/modules"), "/api/dsh-mneme/choro/modules");
153
- assert.equal(ambiguous.status, 400);
154
- assert.equal(ambiguous.body.error, "repo-ambiguous");
155
- assert.equal(ambiguous.body.repos.length, 2);
156
- } finally {
157
- rmTempBestEffort(root2);
158
- }
159
-
160
- const repoId = 1;
161
- const noSlug = await invoke(handler(routes, "/api/dsh-mneme/choro/page"), `/api/dsh-mneme/choro/page?repo=${repoId}`);
162
- assert.equal(noSlug.status, 400);
163
- assert.equal(noSlug.body.error, "slug-required");
164
-
165
- const badSlug = await invoke(handler(routes, "/api/dsh-mneme/choro/page"), `/api/dsh-mneme/choro/page?repo=${repoId}&slug=../etc/passwd`);
166
- assert.equal(badSlug.status, 404, "路径穿越形态的 slug 走未知页 404(slug 只从 DB 白名单解析)");
167
- } finally {
168
- rmTempBestEffort(root);
169
- }
170
- });
171
-
172
- test("地志页路由区分缺失文件和不可读文件", async () => {
173
- const store = createStore(":memory:");
174
- const root = makeRepo(store);
175
- try {
176
- const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
177
- const pageRoute = handler(routes, "/api/dsh-mneme/choro/page");
178
- const url = "/api/dsh-mneme/choro/page?slug=src";
179
- const file = join(root, ".chorography", "modules", "src.md");
180
- unlinkSync(file);
181
- const missing = await invoke(pageRoute, url);
182
- assert.equal(missing.status, 409);
183
- assert.equal(missing.body.error, "page-missing");
184
- assert.match(missing.body.hint, /dsh-mneme-choro/);
185
- mkdirSync(file);
186
- const unreadable = await invoke(pageRoute, url);
187
- assert.equal(unreadable.status, 409);
188
- assert.equal(unreadable.body.error, "page-unreadable");
189
- assert.match(unreadable.body.hint, /permissions/);
190
- } finally {
191
- store.close();
192
- rmTempBestEffort(root);
193
- }
194
- });
195
-
196
- // ---------------------------------------------------------------------------
197
- // 收尾档:POST /choro/run(面板重建入口,P34)+ /choro/search 的 semantic 路(P37–P39)
198
- // ---------------------------------------------------------------------------
199
- test("/choro/run:policy token-write,GET 405,POST 快乐路径重跑 T0", async () => {
200
- const store = createStore(":memory:");
201
- const root = makeRepo(store);
202
- try {
203
- const { routes } = setup({
204
- config: { chorographyEnabled: true },
205
- choro: {
206
- ...createChoroQueries({ store }),
207
- // 与 index.js 的装配同形:runT0Apply 闭包住 store(P35:写路径与读侧分开装配)
208
- runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }),
209
- },
210
- });
211
- const route = handler(routes, "/api/dsh-mneme/choro/run");
212
- assert.equal(route.policy, "token-write", "写仓库磁盘的口归 token-write");
213
-
214
- const get = await invoke(route, "/api/dsh-mneme/choro/run");
215
- assert.equal(get.status, 405, "只收 POST");
216
-
217
- // 破坏页面文件后重建:POST 走真 T0,页回来
218
- rmSync(join(root, ".chorography", "modules", "src.md"));
219
- const ok = await invokePost(route, "/api/dsh-mneme/choro/run", { repo: 1 });
220
- assert.equal(ok.status, 200);
221
- assert.equal(ok.body.ok, true);
222
- assert.ok(ok.body.receipt.moduleCount >= 2, "receipt 带划分计数");
223
- assert.ok(existsSync(join(root, ".chorography", "modules", "src.md")), "重建把页写了回来");
224
- } finally {
225
- rmTempBestEffort(root);
226
- }
227
- });
228
-
229
- test("/choro/run:开关关 409;repo 未知 404(与读路由同一道闸)", async () => {
230
- const store = createStore(":memory:");
231
- const root = makeRepo(store);
232
- try {
233
- const off = setup({
234
- config: { chorographyEnabled: false },
235
- choro: { ...createChoroQueries({ store }), runT0Apply: () => { throw new Error("不该被调到"); } },
236
- });
237
- const a = await invokePost(handler(off.routes, "/api/dsh-mneme/choro/run"), "/api/dsh-mneme/choro/run", { repo: 1 });
238
- assert.equal(a.status, 409);
239
-
240
- const on = setup({
241
- config: { chorographyEnabled: true },
242
- choro: { ...createChoroQueries({ store }), runT0Apply: () => { throw new Error("不该被调到") } },
243
- });
244
- const b = await invokePost(handler(on.routes, "/api/dsh-mneme/choro/run"), "/api/dsh-mneme/choro/run", { repo: 99 });
245
- assert.equal(b.status, 404);
246
- } finally {
247
- rmTempBestEffort(root);
248
- }
249
- });
250
-
251
- test("/choro/search:embedder 在场 → 惰性物化 + semantic 数组;缺席 → semantic null", async () => {
252
- const store = createStore(":memory:");
253
- const root = makeRepo(store);
254
- try {
255
- // stub embedder:向量按内容方向分类(src 相关 → [1,0]),保证可排序断言
256
- const embedder = {
257
- model: "stub-1",
258
- embedSingle: async (text) => (/src|b\.js/.test(text) ? [1, 0] : [0, 1]),
259
- embed: async (text) => (/src|b\.js/.test(text) ? [1, 0] : [0, 1]),
260
- };
261
- const { routes } = setup({
262
- config: { chorographyEnabled: true },
263
- choro: { ...createChoroQueries({ store }), embedder },
264
- });
265
- const s = await invoke(handler(routes, "/api/dsh-mneme/choro/search"), "/api/dsh-mneme/choro/search?repo=1&q=b.js");
266
- assert.equal(s.status, 200);
267
- assert.ok(Array.isArray(s.body.hits) && s.body.hits.length >= 1, "keyword hits 照旧");
268
- assert.ok(Array.isArray(s.body.semantic) && s.body.semantic.length >= 1, "semantic 数组在场(惰性物化发生在这次请求里)");
269
- assert.equal(s.body.semantic[0].slug, "src", "与查询同向的页排前");
270
- assert.equal(typeof s.body.semantic[0].score, "number");
271
-
272
- const noEmbedder = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
273
- const t = await invoke(handler(noEmbedder.routes, "/api/dsh-mneme/choro/search"), "/api/dsh-mneme/choro/search?repo=1&q=b.js");
274
- assert.equal(t.body.semantic, null, "embedder 缺席 → null(调用方退纯关键词)");
275
- } finally {
276
- rmTempBestEffort(root);
277
- }
278
- });
279
-
280
- // ---------------------------------------------------------------------------
281
- // 契约 §3.2(kb-qoder parity):面板闭环要的新字段与新入口
282
- // ---------------------------------------------------------------------------
283
-
284
- test("/choro/repos:契约字段齐备,git 漂移 → stale/git,plan 漂移 → stale/plan", async () => {
285
- const store = createStore(":memory:");
286
- const root = makeRepo(store);
287
- try {
288
- const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
289
- const url = "/api/dsh-mneme/choro/repos";
290
-
291
- // 刚重建完:不该有任何过期信号——否则面板会对着一个刚生成的地志喊「代码已变」,
292
- // 用户第一次用就学会不信任那个标记。
293
- const fresh = await invoke(handler(routes, url), url);
294
- assert.equal(fresh.status, 200);
295
- const a = fresh.body.repos[0];
296
- for (const key of ["id", "name", "rootPath", "modules", "pages", "claims", "legacy", "stale", "staleReason", "lastRunAt"]) {
297
- assert.ok(key in a, `契约字段 ${key} 必须在场(面板按它渲染)`);
298
- }
299
- assert.equal(a.stale, false);
300
- assert.equal(a.staleReason, null);
301
- assert.equal(a.legacy, false);
302
- assert.equal(typeof a.lastRunAt, "string");
303
- assert.ok(a.modules >= 2 && a.pages >= 2, "modules/pages 是契约字段名(不是行里的 module_count)");
304
-
305
- // 代码变了(新 commit)→ 说过期,理由必须是 git:面板那句话是「代码已变」。
306
- writeFileSync(join(root, "src", "c.js"), "export const c = () => 2;\n", "utf8");
307
- execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
308
- execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "drift", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
309
- const drifted = await invoke(handler(routes, url), url);
310
- assert.equal(drifted.body.repos[0].stale, true);
311
- assert.equal(drifted.body.repos[0].staleReason, "git");
312
-
313
- // 先重建一次把 git 基线推平(否则 git 那一路永远优先,plan 判据根本轮不到),
314
- // 再只改 plan.yaml(HEAD 不动)→ 理由必须是 plan:这条判据不依赖 git,不能被
315
- // git 那一路掩盖掉,否则「改了模板配置但没提交」的仓库永远显示干净。
316
- runChoroT0({ repoRoot: root, store, apply: true });
317
- const cleaned = await invoke(handler(routes, url), url);
318
- assert.equal(cleaned.body.repos[0].stale, false, "重建后 git 基线推平,过期信号应清掉");
319
- writeFileSync(join(root, ".chorography", "plan.yaml"), "pages: []\n", "utf8");
320
- const planDrift = await invoke(handler(routes, url), url);
321
- assert.equal(planDrift.body.repos[0].stale, true);
322
- assert.equal(planDrift.body.repos[0].staleReason, "plan");
323
- } finally {
324
- rmTempBestEffort(root);
325
- }
326
- });
327
-
328
- test("/choro/run:空态按绝对路径登记新仓库,重复路径复用同一 id", async () => {
329
- const store = createStore(":memory:");
330
- // 只把仓库目录准备好但不登记:这就是面板「一个仓库都没登记」的真实起点。
331
- const root = mkdtempSync(join(tmpdir(), "choro-empty-"));
332
- writeFileSync(join(root, "package.json"), JSON.stringify({ name: "emptyfix" }, null, 2) + "\n", "utf8");
333
- mkdirSync(join(root, "src"), { recursive: true });
334
- writeFileSync(join(root, "src", "a.js"), "export const a = 1;\n", "utf8");
335
- execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
336
- execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
337
- execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
338
- try {
339
- const { routes } = setup({
340
- config: { chorographyEnabled: true },
341
- choro: { ...createChoroQueries({ store }), runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }) }
342
- });
343
- const route = handler(routes, "/api/dsh-mneme/choro/run");
344
- const reposUrl = "/api/dsh-mneme/choro/repos";
345
- const before = await invoke(handler(routes, reposUrl), reposUrl);
346
- assert.equal(before.body.repos.length, 0, "起点确实是一个仓库都没登记");
347
-
348
- const first = await invokePost(route, "/api/dsh-mneme/choro/run", { path: root });
349
- assert.equal(first.status, 200);
350
- assert.equal(first.body.ok, true);
351
- assert.ok(first.body.receipt.moduleCount >= 1, "走的是真 T0(有划分回执)");
352
- const after = await invoke(handler(routes, reposUrl), reposUrl);
353
- assert.equal(after.body.repos.length, 1, "空态 + 路径 → 自动登记");
354
-
355
- // 第二次同一路径:不能变成两条记录,也不能报错——面板按钮会被连点。
356
- const again = await invokePost(route, "/api/dsh-mneme/choro/run", { rootPath: root, refresh: true });
357
- assert.equal(again.status, 200);
358
- assert.equal(again.body.refresh, true, "refresh 原样回显,面板据此区分「我要求过重跑」");
359
- assert.equal((await invoke(handler(routes, reposUrl), reposUrl)).body.repos.length, 1, "同一路径复用既有 id");
360
- } finally {
361
- rmTempBestEffort(root);
362
- }
363
- });
364
-
365
- test("/choro/run:路径给错时结构化报错,不落 500 一锅烩", async () => {
366
- const store = createStore(":memory:");
367
- const notGit = mkdtempSync(join(tmpdir(), "choro-notgit-"));
368
- try {
369
- const { routes } = setup({
370
- config: { chorographyEnabled: true },
371
- choro: { ...createChoroQueries({ store }), runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }) }
372
- });
373
- const route = handler(routes, "/api/dsh-mneme/choro/run");
374
-
375
- const rel = await invokePost(route, "/api/dsh-mneme/choro/run", { path: "some/rel/dir" });
376
- assert.equal(rel.status, 400);
377
- assert.equal(rel.body.error, "path-not-absolute", "相对路径的语义取决于宿主 cwd,必须挡");
378
-
379
- const missing = await invokePost(route, "/api/dsh-mneme/choro/run", { path: join(notGit, "nope") });
380
- assert.equal(missing.status, 400);
381
- assert.equal(missing.body.error, "path-not-found");
382
-
383
- const plain = await invokePost(route, "/api/dsh-mneme/choro/run", { path: notGit });
384
- assert.equal(plain.status, 409);
385
- assert.equal(plain.body.error, "not-a-git-repo", "目录在但不是 git 仓库 → 409,不是 500");
386
- } finally {
387
- rmTempBestEffort(notGit);
388
- }
389
- });
390
-
391
- // T1 会真花钱(模型调用),所以这里注入假的 startT1Job / t1JobStatus:
392
- // 路由层要锁的只是闸门与 200/409/404 的分叉形状,不是进程本身。
393
- function withT1({ startT1Job, t1JobStatus } = {}) {
394
- const store = createStore(":memory:");
395
- const root = makeRepo(store);
396
- const choro = { ...createChoroQueries({ store }) };
397
- if (startT1Job) choro.startT1Job = startT1Job;
398
- if (t1JobStatus) choro.t1JobStatus = t1JobStatus;
399
- return { store, root, choro };
400
- }
401
-
402
- test("/choro/t1:缺装配 503 / 面板闸关 409 / 同仓并发 409 job-running / 缺省 404", async () => {
403
- const a = withT1({});
404
- try {
405
- const noJobs = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: a.choro });
406
- const missing = await invokePost(handler(noJobs.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
407
- assert.equal(missing.status, 503, "没装配任务账 → 503 环境缺件");
408
- assert.equal(missing.body.error, "unavailable");
409
- } finally {
410
- rmTempBestEffort(a.root);
411
- }
412
-
413
- const b = withT1({ startT1Job: () => ({ started: true, jobId: "j-1" }) });
414
- try {
415
- const off = setup({
416
- config: { chorographyEnabled: true, chorographyT1FromPanel: false },
417
- choro: b.choro
418
- });
419
- const disabled = await invokePost(handler(off.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
420
- assert.equal(disabled.status, 409, "T1 花模型额度,面板闸关着就不许起");
421
- assert.equal(disabled.body.error, "disabled");
422
-
423
- const on = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: b.choro });
424
- const t1Route = handler(on.routes, "/api/dsh-mneme/choro/t1");
425
- assert.equal(t1Route.policy, "token-write", "起一个会写页面/claims 的后台任务属数据面写");
426
-
427
- const ok = await invokePost(t1Route, "/api/dsh-mneme/choro/t1", { repo: 1, module: "src", retryFailed: true });
428
- assert.equal(ok.status, 200);
429
- assert.deepEqual(ok.body, { jobId: "j-1", started: true });
430
-
431
- const unknown = await invokePost(t1Route, "/api/dsh-mneme/choro/t1", { repo: 99 });
432
- assert.equal(unknown.status, 404);
433
-
434
- const busy = withT1({ startT1Job: () => ({ started: false, code: "job-running", jobId: "j-2" }) });
435
- try {
436
- const busySetup = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: busy.choro });
437
- const running = await invokePost(handler(busySetup.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
438
- assert.equal(running.status, 409);
439
- assert.equal(running.body.error, "job-running");
440
- assert.equal(running.body.jobId, "j-2", "把在跑的那个 jobId 带回给面板,好让它接着轮询而不是重起");
441
- } finally {
442
- rmTempBestEffort(busy.root);
443
- }
444
- } finally {
445
- rmTempBestEffort(b.root);
446
- }
447
- });
448
-
449
- test("/choro/t1/status:jobId 缺 400、未知 404、已知 200 带契约字段", async () => {
450
- const job = {
451
- jobId: "j-9",
452
- repo: { id: 1, name: "x", rootPath: "/x" },
453
- state: "running",
454
- startedAt: "T0",
455
- modules: { total: 2, done: 1, failed: 0 },
456
- logTail: ["line"]
457
- };
458
- const f = withT1({ startT1Job: () => ({ started: true, jobId: "j-9" }), t1JobStatus: (id) => (id === "j-9" ? job : null) });
459
- try {
460
- const { routes } = setup({ config: { chorographyEnabled: true }, choro: f.choro });
461
- const route = handler(routes, "/api/dsh-mneme/choro/t1/status");
462
-
463
- const noId = await invoke(route, "/api/dsh-mneme/choro/t1/status");
464
- assert.equal(noId.status, 400);
465
- assert.equal(noId.body.error, "jobId-required");
466
-
467
- const gone = await invoke(route, "/api/dsh-mneme/choro/t1/status?jobId=nope");
468
- assert.equal(gone.status, 404, "宿主重启后旧 jobId 必然查不到,面板据此回到「没有在跑的任务」");
469
-
470
- const found = await invoke(route, "/api/dsh-mneme/choro/t1/status?jobId=j-9");
471
- assert.equal(found.status, 200);
472
- assert.equal(found.body.state, "running");
473
- assert.equal(found.body.modules.total, 2);
474
- assert.deepEqual(found.body.logTail, ["line"]);
475
- } finally {
476
- rmTempBestEffort(f.root);
477
- }
478
- });
479
-
480
- // ---------------------------------------------------------------------------
481
- // chorographyAutoRefresh:读侧自动重跑 T0(面板文案已经把它当契约写死了:
482
- // 「读地志页时如果发现仓库代码变了,自动重跑一次 T0 骨架」)。
483
- // 这一批同时锁「开关关着绝不偷偷改用户工作区」与「开关开着真的会刷」——只锁一端
484
- // 的测试在实现成「永远不刷」或「永远刷」时都会绿,等于没锁。
485
- // ---------------------------------------------------------------------------
486
-
487
- // 计数外壳:真实 queries + 包一层的 runT0Apply(记调用次数、可注入失败)。
488
- // 不造一个假 T0——「刷了但基线没推进」这种 bug 只有真 T0 才照得出来。
489
- function countingChoro(store, { throwOnRun = false } = {}) {
490
- const calls = [];
491
- const base = createChoroQueries({ store });
492
- return {
493
- calls,
494
- choro: {
495
- ...base,
496
- runT0Apply: ({ repoRoot }) => {
497
- calls.push(repoRoot);
498
- if (throwOnRun) throw new Error("磁盘只读(模拟)");
499
- return runChoroT0({ repoRoot, store, apply: true });
500
- }
501
- }
502
- };
503
- }
504
-
505
- // 造一个「代码已变」的仓库:登记时的 git_baseline 与当前 HEAD 不同。
506
- function driftRepo(store) {
507
- const root = makeRepo(store);
508
- writeFileSync(join(root, "src", "c.js"), "export const c = () => 2;\n", "utf8");
509
- execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
510
- execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "drift", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
511
- return root;
512
- }
513
-
514
- test("/choro/repos 自动重跑:开关关着时即使过期也一次都不刷(默认行为逐字节不变)", async () => {
515
- const store = createStore(":memory:");
516
- const root = driftRepo(store);
517
- try {
518
- const { calls, choro } = countingChoro(store);
519
- const { routes } = setup({ config: { chorographyEnabled: true }, choro });
520
- const url = "/api/dsh-mneme/choro/repos?repo=1";
521
- const REPOS_PATH = "/api/dsh-mneme/choro/repos";
522
- const res = await invoke(handler(routes, REPOS_PATH), url);
523
- assert.equal(res.status, 200);
524
- assert.equal(res.body.repos[0].staleReason, "git", "前提:这个仓库确实是过期的");
525
- assert.equal(calls.length, 0, "开关缺省 false 时读侧一个字节都不许动用户的工作区");
526
- assert.equal(res.body.autoRefreshed, false);
527
- } finally {
528
- rmTempBestEffort(root);
529
- }
530
- });
531
-
532
- test("/choro/repos 自动重跑:开关开 + ?repo= 点名且 git 过期 → 刷一次,回执按新基线说话", async () => {
533
- const store = createStore(":memory:");
534
- const root = driftRepo(store);
535
- try {
536
- const { calls, choro } = countingChoro(store);
537
- const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
538
- const url = "/api/dsh-mneme/choro/repos?repo=1";
539
- const REPOS_PATH = "/api/dsh-mneme/choro/repos";
540
- const res = await invoke(handler(routes, REPOS_PATH), url);
541
- assert.equal(res.status, 200);
542
- assert.equal(calls.length, 1, "开关开 + 指名 + git 过期 = 恰好刷这一个仓库");
543
- assert.equal(res.body.autoRefreshed, true);
544
- // 刷完必须改成不 stale:否则面板刚刷完就显示「代码已变」,等于谎报一次失败。
545
- assert.equal(res.body.repos[0].stale, false);
546
- assert.equal(res.body.repos[0].staleReason, null);
547
- } finally {
548
- rmTempBestEffort(root);
549
- }
550
- });
551
-
552
- test("/choro/repos 自动重跑:不 stale 或没点名 ?repo= 时都不刷(一次面板加载不能扫全库)", async () => {
553
- const store = createStore(":memory:");
554
- const cleanRoot = makeRepo(store);
555
- try {
556
- const { calls, choro } = countingChoro(store);
557
- const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
558
- const plain = "/api/dsh-mneme/choro/repos";
559
- const clean = await invoke(handler(routes, plain), plain);
560
- assert.equal(clean.status, 200);
561
- assert.equal(calls.length, 0, "staleReason 为 null → 没有刷的理由");
562
-
563
- // 造第二个过期仓库,但请求不带 ?repo=:列表路径一个都不许刷。
564
- const drifted = driftRepo(store);
565
- try {
566
- const listed = await invoke(handler(routes, plain), plain);
567
- assert.equal(listed.body.repos.length, 2);
568
- assert.equal(calls.length, 0, "没点名就不刷:否则打开面板要付 N 次全仓扫描");
569
- assert.equal(listed.body.autoRefreshed, false);
570
- assert.ok(listed.body.repos.some((r) => r.staleReason === "git"), "过期信号照常回,只是不顺手刷");
571
- } finally {
572
- rmTempBestEffort(drifted);
573
- }
574
- } finally {
575
- rmTempBestEffort(cleanRoot);
576
- }
577
- });
578
-
579
- test("/choro/repos 自动重跑失败:读照常 200、数据在、错误进 autoRefreshError", async () => {
580
- const store = createStore(":memory:");
581
- const root = driftRepo(store);
582
- try {
583
- const { calls, choro } = countingChoro(store, { throwOnRun: true });
584
- const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
585
- const url = "/api/dsh-mneme/choro/repos?repo=1";
586
- const REPOS_PATH = "/api/dsh-mneme/choro/repos";
587
- const res = await invoke(handler(routes, REPOS_PATH), url);
588
- // 读是主诉求、刷新是附赠:打成 500 等于「顺手的事没办成,正事也不给办」。
589
- assert.equal(res.status, 200, "刷新失败不许把读请求打成 500");
590
- assert.equal(calls.length, 1);
591
- assert.equal(res.body.autoRefreshed, false);
592
- assert.match(res.body.autoRefreshError, /磁盘只读/);
593
- assert.equal(res.body.repos.length, 1, "数据必须还在");
594
- assert.equal(res.body.repos[0].staleReason, "git", "没刷成功就照实说过期");
595
- } finally {
596
- rmTempBestEffort(root);
597
- }
598
- });
599
-
600
- // /choro/page 是面板主路径,也是「读地志页时发现代码变了」那句文案的真正时刻。
601
- // 顶栏「代码已变」徽章读 state.repo.stale —— 回执里没有 repo 这份数据,徽章永远不亮。
602
- test("/choro/page 回执带 repo.stale/staleReason,并在这条读路径上自动重跑", async () => {
603
- const store = createStore(":memory:");
604
- const root = driftRepo(store);
605
- try {
606
- const { calls, choro } = countingChoro(store);
607
- const url = "/api/dsh-mneme/choro/page?repo=1&slug=src";
608
- const PAGE_PATH = "/api/dsh-mneme/choro/page";
609
-
610
- const offSetup = setup({ config: { chorographyEnabled: true }, choro });
611
- const off = await invoke(handler(offSetup.routes, PAGE_PATH), url);
612
- assert.equal(off.status, 200);
613
- assert.equal(off.body.repo.stale, true, "徽章的数据源:开关关着也要照实报过期");
614
- assert.equal(off.body.repo.staleReason, "git");
615
- assert.equal(off.body.autoRefreshed, false);
616
- assert.equal(calls.length, 0);
617
-
618
- const onSetup = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
619
- const on = await invoke(handler(onSetup.routes, PAGE_PATH), url);
620
- assert.equal(on.status, 200);
621
- assert.equal(calls.length, 1, "页读路径点名的就是这一个仓库");
622
- assert.equal(on.body.autoRefreshed, true);
623
- assert.equal(on.body.repo.stale, false, "刷完页面与 repo 徽章必须同一时刻一致");
624
- assert.ok(on.body.page.content.includes("## 文件清单"), "页正文照常回");
625
- } finally {
626
- rmTempBestEffort(root);
627
- }
1
+ // chorography 四条读路由的回归锁(M1)。锁四层闸的语义分叉(#497 口径):
2
+ // choro queries 缺装配 → 503 unavailable(环境缺件);chorographyEnabled 关 →
3
+ // 409 disabled(用户没开);repo 解析失败 → 404 / 歧义 → 400 带可选清单;
4
+ // 快乐路径四条全通。数据用真实 T0 fixture 建(同 wiki-queries 的对账纪律)。
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { EventEmitter } from "node:events";
8
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { execFileSync } from "node:child_process";
12
+ import { createStore } from "../src/store.js";
13
+ import { createService } from "../src/service.js";
14
+ import { createApi } from "../src/api.js";
15
+ import { createSettings } from "../src/settings.js";
16
+ import { createChoroQueries } from "../src/wiki/queries.js";
17
+ import { runChoroT0 } from "../src/wiki/t0.js";
18
+
19
+ class FakeRes extends EventEmitter {
20
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
21
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
22
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
23
+ }
24
+
25
+ function req(path, method = "GET") {
26
+ const r = new EventEmitter();
27
+ r.url = path;
28
+ r.method = method;
29
+ r.headers = {};
30
+ return r;
31
+ }
32
+
33
+ function setup({ config = null, choro = null } = {}) {
34
+ const store = createStore(":memory:");
35
+ const service = createService({ store, mirror: null, config: {} });
36
+ const settings = createSettings(store.db);
37
+ const routes = [];
38
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
39
+ createApi(ctx, service, settings, { add: () => {}, remove: () => false, list: () => [] }, null, undefined, "", config, null, null, choro);
40
+ return { routes };
41
+ }
42
+
43
+ function handler(routes, path) {
44
+ return routes.find((r) => r.path === path);
45
+ }
46
+
47
+ async function invoke(route, path) {
48
+ const res = new FakeRes();
49
+ await route.handler(req(path), res);
50
+ return { status: res.statusCode, body: JSON.parse(res.body || "{}") };
51
+ }
52
+
53
+ // POST 版:带 JSON body(readBody 走 data/end 事件)
54
+ async function invokePost(route, path, body) {
55
+ const r = req(path, "POST");
56
+ process.nextTick(() => {
57
+ r.emit("data", Buffer.from(JSON.stringify(body ?? {})));
58
+ r.emit("end");
59
+ });
60
+ const res = new FakeRes();
61
+ await route.handler(r, res);
62
+ return { status: res.statusCode, body: JSON.parse(res.body || "{}") };
63
+ }
64
+
65
+ function makeRepo(store) {
66
+ const root = mkdtempSync(join(tmpdir(), "choro-api-"));
67
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "apifix", scripts: { test: "node --test" } }, null, 2) + "\n", "utf8");
68
+ mkdirSync(join(root, "src"), { recursive: true });
69
+ writeFileSync(join(root, "src", "a.js"), 'import { b } from "./b.js";\nexport const a = () => b();\n', "utf8");
70
+ writeFileSync(join(root, "src", "b.js"), "export const b = () => 1;\n", "utf8");
71
+ execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
72
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
73
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
74
+ runChoroT0({ repoRoot: root, store, apply: true, now: () => "T" });
75
+ return root;
76
+ }
77
+
78
+ function rmTempBestEffort(dir) {
79
+ try {
80
+ rmSync(dir, { recursive: true, force: true });
81
+ } catch {
82
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
83
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* 留给系统清理 */ }
84
+ }
85
+ }
86
+
87
+ test("四条 choro 路由都在册且 policy 全 open", () => {
88
+ const { routes } = setup({});
89
+ for (const p of ["/api/dsh-mneme/choro/repos", "/api/dsh-mneme/choro/modules", "/api/dsh-mneme/choro/page", "/api/dsh-mneme/choro/search"]) {
90
+ const route = handler(routes, p);
91
+ assert.ok(route, `${p} 在册`);
92
+ assert.equal(route.policy, "open");
93
+ }
94
+ });
95
+
96
+ test("queries 缺装配 → 503;开关关 → 409 disabled(语义分叉)", async () => {
97
+ const missing = setup({});
98
+ const reposRoute = handler(missing.routes, "/api/dsh-mneme/choro/repos");
99
+ const a = await invoke(reposRoute, "/api/dsh-mneme/choro/repos");
100
+ assert.equal(a.status, 503);
101
+ assert.equal(a.body.error, "unavailable");
102
+
103
+ const off = setup({ config: { chorographyEnabled: false }, choro: createChoroQueries({ store: createStore(":memory:") }) });
104
+ const b = await invoke(handler(off.routes, "/api/dsh-mneme/choro/repos"), "/api/dsh-mneme/choro/repos");
105
+ assert.equal(b.status, 409);
106
+ assert.equal(b.body.error, "disabled");
107
+ });
108
+
109
+ test("快乐路径:repos / modules / page / search 全通", async () => {
110
+ const store = createStore(":memory:");
111
+ const root = makeRepo(store);
112
+ try {
113
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
114
+ const repos = await invoke(handler(routes, "/api/dsh-mneme/choro/repos"), "/api/dsh-mneme/choro/repos");
115
+ assert.equal(repos.status, 200);
116
+ assert.equal(repos.body.repos.length, 1);
117
+ const repoId = repos.body.repos[0].id;
118
+
119
+ const modules = await invoke(handler(routes, "/api/dsh-mneme/choro/modules"), `/api/dsh-mneme/choro/modules?repo=${repoId}`);
120
+ assert.equal(modules.status, 200);
121
+ assert.ok(modules.body.modules.length >= 2);
122
+ assert.ok(Array.isArray(modules.body.edges));
123
+ // 面板的地志页导入要把「根路径 + 页相对路径」拼成磁盘绝对路径(契约 §3.6),
124
+ // 回执里缺 relPath 时它会静默回落到「请先选模块」——这条锁的就是那个真机 bug。
125
+ const withPage = modules.body.modules.filter((m) => m.page_rel_path);
126
+ assert.ok(withPage.length >= 1, "fixture 里至少一个模块有骨架页");
127
+ for (const m of withPage) assert.equal(m.relPath, m.page_rel_path);
128
+
129
+ const page = await invoke(handler(routes, "/api/dsh-mneme/choro/page"), `/api/dsh-mneme/choro/page?repo=${repoId}&slug=src`);
130
+ assert.equal(page.status, 200);
131
+ assert.ok(page.body.page.content.includes("## 文件清单"));
132
+
133
+ const search = await invoke(handler(routes, "/api/dsh-mneme/choro/search"), `/api/dsh-mneme/choro/search?repo=${repoId}&q=b.js`);
134
+ assert.equal(search.status, 200);
135
+ assert.ok(search.body.hits.length >= 1);
136
+ } finally {
137
+ rmTempBestEffort(root);
138
+ }
139
+ });
140
+
141
+ test("repo 解析失败各归各位:未知 → 404;歧义 → 400 带清单;slug 缺 → 400", async () => {
142
+ const store = createStore(":memory:");
143
+ const root = makeRepo(store);
144
+ try {
145
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
146
+ const notFound = await invoke(handler(routes, "/api/dsh-mneme/choro/modules"), "/api/dsh-mneme/choro/modules?repo=nope");
147
+ assert.equal(notFound.status, 404);
148
+ assert.equal(notFound.body.error, "repo-not-found");
149
+
150
+ const root2 = makeRepo(store);
151
+ try {
152
+ const ambiguous = await invoke(handler(routes, "/api/dsh-mneme/choro/modules"), "/api/dsh-mneme/choro/modules");
153
+ assert.equal(ambiguous.status, 400);
154
+ assert.equal(ambiguous.body.error, "repo-ambiguous");
155
+ assert.equal(ambiguous.body.repos.length, 2);
156
+ } finally {
157
+ rmTempBestEffort(root2);
158
+ }
159
+
160
+ const repoId = 1;
161
+ const noSlug = await invoke(handler(routes, "/api/dsh-mneme/choro/page"), `/api/dsh-mneme/choro/page?repo=${repoId}`);
162
+ assert.equal(noSlug.status, 400);
163
+ assert.equal(noSlug.body.error, "slug-required");
164
+
165
+ const badSlug = await invoke(handler(routes, "/api/dsh-mneme/choro/page"), `/api/dsh-mneme/choro/page?repo=${repoId}&slug=../etc/passwd`);
166
+ assert.equal(badSlug.status, 404, "路径穿越形态的 slug 走未知页 404(slug 只从 DB 白名单解析)");
167
+ } finally {
168
+ rmTempBestEffort(root);
169
+ }
170
+ });
171
+
172
+ test("地志页路由区分缺失文件和不可读文件", async () => {
173
+ const store = createStore(":memory:");
174
+ const root = makeRepo(store);
175
+ try {
176
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
177
+ const pageRoute = handler(routes, "/api/dsh-mneme/choro/page");
178
+ const url = "/api/dsh-mneme/choro/page?slug=src";
179
+ const file = join(root, ".chorography", "modules", "src.md");
180
+ unlinkSync(file);
181
+ const missing = await invoke(pageRoute, url);
182
+ assert.equal(missing.status, 409);
183
+ assert.equal(missing.body.error, "page-missing");
184
+ assert.match(missing.body.hint, /dsh-mneme-choro/);
185
+ mkdirSync(file);
186
+ const unreadable = await invoke(pageRoute, url);
187
+ assert.equal(unreadable.status, 409);
188
+ assert.equal(unreadable.body.error, "page-unreadable");
189
+ assert.match(unreadable.body.hint, /permissions/);
190
+ } finally {
191
+ store.close();
192
+ rmTempBestEffort(root);
193
+ }
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // 收尾档:POST /choro/run(面板重建入口,P34)+ /choro/search 的 semantic 路(P37–P39)
198
+ // ---------------------------------------------------------------------------
199
+ test("/choro/run:policy token-write,GET 405,POST 快乐路径重跑 T0", async () => {
200
+ const store = createStore(":memory:");
201
+ const root = makeRepo(store);
202
+ try {
203
+ const { routes } = setup({
204
+ config: { chorographyEnabled: true },
205
+ choro: {
206
+ ...createChoroQueries({ store }),
207
+ // 与 index.js 的装配同形:runT0Apply 闭包住 store(P35:写路径与读侧分开装配)
208
+ runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }),
209
+ },
210
+ });
211
+ const route = handler(routes, "/api/dsh-mneme/choro/run");
212
+ assert.equal(route.policy, "token-write", "写仓库磁盘的口归 token-write");
213
+
214
+ const get = await invoke(route, "/api/dsh-mneme/choro/run");
215
+ assert.equal(get.status, 405, "只收 POST");
216
+
217
+ // 破坏页面文件后重建:POST 走真 T0,页回来
218
+ rmSync(join(root, ".chorography", "modules", "src.md"));
219
+ const ok = await invokePost(route, "/api/dsh-mneme/choro/run", { repo: 1 });
220
+ assert.equal(ok.status, 200);
221
+ assert.equal(ok.body.ok, true);
222
+ assert.ok(ok.body.receipt.moduleCount >= 2, "receipt 带划分计数");
223
+ assert.ok(existsSync(join(root, ".chorography", "modules", "src.md")), "重建把页写了回来");
224
+ } finally {
225
+ rmTempBestEffort(root);
226
+ }
227
+ });
228
+
229
+ test("/choro/run:开关关 409;repo 未知 404(与读路由同一道闸)", async () => {
230
+ const store = createStore(":memory:");
231
+ const root = makeRepo(store);
232
+ try {
233
+ const off = setup({
234
+ config: { chorographyEnabled: false },
235
+ choro: { ...createChoroQueries({ store }), runT0Apply: () => { throw new Error("不该被调到"); } },
236
+ });
237
+ const a = await invokePost(handler(off.routes, "/api/dsh-mneme/choro/run"), "/api/dsh-mneme/choro/run", { repo: 1 });
238
+ assert.equal(a.status, 409);
239
+
240
+ const on = setup({
241
+ config: { chorographyEnabled: true },
242
+ choro: { ...createChoroQueries({ store }), runT0Apply: () => { throw new Error("不该被调到") } },
243
+ });
244
+ const b = await invokePost(handler(on.routes, "/api/dsh-mneme/choro/run"), "/api/dsh-mneme/choro/run", { repo: 99 });
245
+ assert.equal(b.status, 404);
246
+ } finally {
247
+ rmTempBestEffort(root);
248
+ }
249
+ });
250
+
251
+ test("/choro/search:embedder 在场 → 惰性物化 + semantic 数组;缺席 → semantic null", async () => {
252
+ const store = createStore(":memory:");
253
+ const root = makeRepo(store);
254
+ try {
255
+ // stub embedder:向量按内容方向分类(src 相关 → [1,0]),保证可排序断言
256
+ const embedder = {
257
+ model: "stub-1",
258
+ embedSingle: async (text) => (/src|b\.js/.test(text) ? [1, 0] : [0, 1]),
259
+ embed: async (text) => (/src|b\.js/.test(text) ? [1, 0] : [0, 1]),
260
+ };
261
+ const { routes } = setup({
262
+ config: { chorographyEnabled: true },
263
+ choro: { ...createChoroQueries({ store }), embedder },
264
+ });
265
+ const s = await invoke(handler(routes, "/api/dsh-mneme/choro/search"), "/api/dsh-mneme/choro/search?repo=1&q=b.js");
266
+ assert.equal(s.status, 200);
267
+ assert.ok(Array.isArray(s.body.hits) && s.body.hits.length >= 1, "keyword hits 照旧");
268
+ assert.ok(Array.isArray(s.body.semantic) && s.body.semantic.length >= 1, "semantic 数组在场(惰性物化发生在这次请求里)");
269
+ assert.equal(s.body.semantic[0].slug, "src", "与查询同向的页排前");
270
+ assert.equal(typeof s.body.semantic[0].score, "number");
271
+
272
+ const noEmbedder = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
273
+ const t = await invoke(handler(noEmbedder.routes, "/api/dsh-mneme/choro/search"), "/api/dsh-mneme/choro/search?repo=1&q=b.js");
274
+ assert.equal(t.body.semantic, null, "embedder 缺席 → null(调用方退纯关键词)");
275
+ } finally {
276
+ rmTempBestEffort(root);
277
+ }
278
+ });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // 契约 §3.2(kb-qoder parity):面板闭环要的新字段与新入口
282
+ // ---------------------------------------------------------------------------
283
+
284
+ test("/choro/repos:契约字段齐备,git 漂移 → stale/git,plan 漂移 → stale/plan", async () => {
285
+ const store = createStore(":memory:");
286
+ const root = makeRepo(store);
287
+ try {
288
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
289
+ const url = "/api/dsh-mneme/choro/repos";
290
+
291
+ // 刚重建完:不该有任何过期信号——否则面板会对着一个刚生成的地志喊「代码已变」,
292
+ // 用户第一次用就学会不信任那个标记。
293
+ const fresh = await invoke(handler(routes, url), url);
294
+ assert.equal(fresh.status, 200);
295
+ const a = fresh.body.repos[0];
296
+ for (const key of ["id", "name", "rootPath", "modules", "pages", "claims", "legacy", "stale", "staleReason", "lastRunAt"]) {
297
+ assert.ok(key in a, `契约字段 ${key} 必须在场(面板按它渲染)`);
298
+ }
299
+ assert.equal(a.stale, false);
300
+ assert.equal(a.staleReason, null);
301
+ assert.equal(a.legacy, false);
302
+ assert.equal(typeof a.lastRunAt, "string");
303
+ assert.ok(a.modules >= 2 && a.pages >= 2, "modules/pages 是契约字段名(不是行里的 module_count)");
304
+
305
+ // 代码变了(新 commit)→ 说过期,理由必须是 git:面板那句话是「代码已变」。
306
+ writeFileSync(join(root, "src", "c.js"), "export const c = () => 2;\n", "utf8");
307
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
308
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "drift", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
309
+ const drifted = await invoke(handler(routes, url), url);
310
+ assert.equal(drifted.body.repos[0].stale, true);
311
+ assert.equal(drifted.body.repos[0].staleReason, "git");
312
+
313
+ // 先重建一次把 git 基线推平(否则 git 那一路永远优先,plan 判据根本轮不到),
314
+ // 再只改 plan.yaml(HEAD 不动)→ 理由必须是 plan:这条判据不依赖 git,不能被
315
+ // git 那一路掩盖掉,否则「改了模板配置但没提交」的仓库永远显示干净。
316
+ runChoroT0({ repoRoot: root, store, apply: true });
317
+ const cleaned = await invoke(handler(routes, url), url);
318
+ assert.equal(cleaned.body.repos[0].stale, false, "重建后 git 基线推平,过期信号应清掉");
319
+ writeFileSync(join(root, ".chorography", "plan.yaml"), "pages: []\n", "utf8");
320
+ const planDrift = await invoke(handler(routes, url), url);
321
+ assert.equal(planDrift.body.repos[0].stale, true);
322
+ assert.equal(planDrift.body.repos[0].staleReason, "plan");
323
+ } finally {
324
+ rmTempBestEffort(root);
325
+ }
326
+ });
327
+
328
+ test("/choro/run:空态按绝对路径登记新仓库,重复路径复用同一 id", async () => {
329
+ const store = createStore(":memory:");
330
+ // 只把仓库目录准备好但不登记:这就是面板「一个仓库都没登记」的真实起点。
331
+ const root = mkdtempSync(join(tmpdir(), "choro-empty-"));
332
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "emptyfix" }, null, 2) + "\n", "utf8");
333
+ mkdirSync(join(root, "src"), { recursive: true });
334
+ writeFileSync(join(root, "src", "a.js"), "export const a = 1;\n", "utf8");
335
+ execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
336
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
337
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
338
+ try {
339
+ const { routes } = setup({
340
+ config: { chorographyEnabled: true },
341
+ choro: { ...createChoroQueries({ store }), runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }) }
342
+ });
343
+ const route = handler(routes, "/api/dsh-mneme/choro/run");
344
+ const reposUrl = "/api/dsh-mneme/choro/repos";
345
+ const before = await invoke(handler(routes, reposUrl), reposUrl);
346
+ assert.equal(before.body.repos.length, 0, "起点确实是一个仓库都没登记");
347
+
348
+ const first = await invokePost(route, "/api/dsh-mneme/choro/run", { path: root });
349
+ assert.equal(first.status, 200);
350
+ assert.equal(first.body.ok, true);
351
+ assert.ok(first.body.receipt.moduleCount >= 1, "走的是真 T0(有划分回执)");
352
+ const after = await invoke(handler(routes, reposUrl), reposUrl);
353
+ assert.equal(after.body.repos.length, 1, "空态 + 路径 → 自动登记");
354
+
355
+ // 第二次同一路径:不能变成两条记录,也不能报错——面板按钮会被连点。
356
+ const again = await invokePost(route, "/api/dsh-mneme/choro/run", { rootPath: root, refresh: true });
357
+ assert.equal(again.status, 200);
358
+ assert.equal(again.body.refresh, true, "refresh 原样回显,面板据此区分「我要求过重跑」");
359
+ assert.equal((await invoke(handler(routes, reposUrl), reposUrl)).body.repos.length, 1, "同一路径复用既有 id");
360
+ } finally {
361
+ rmTempBestEffort(root);
362
+ }
363
+ });
364
+
365
+ test("/choro/run:路径给错时结构化报错,不落 500 一锅烩", async () => {
366
+ const store = createStore(":memory:");
367
+ const notGit = mkdtempSync(join(tmpdir(), "choro-notgit-"));
368
+ try {
369
+ const { routes } = setup({
370
+ config: { chorographyEnabled: true },
371
+ choro: { ...createChoroQueries({ store }), runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }) }
372
+ });
373
+ const route = handler(routes, "/api/dsh-mneme/choro/run");
374
+
375
+ const rel = await invokePost(route, "/api/dsh-mneme/choro/run", { path: "some/rel/dir" });
376
+ assert.equal(rel.status, 400);
377
+ assert.equal(rel.body.error, "path-not-absolute", "相对路径的语义取决于宿主 cwd,必须挡");
378
+
379
+ const missing = await invokePost(route, "/api/dsh-mneme/choro/run", { path: join(notGit, "nope") });
380
+ assert.equal(missing.status, 400);
381
+ assert.equal(missing.body.error, "path-not-found");
382
+
383
+ const plain = await invokePost(route, "/api/dsh-mneme/choro/run", { path: notGit });
384
+ assert.equal(plain.status, 409);
385
+ assert.equal(plain.body.error, "not-a-git-repo", "目录在但不是 git 仓库 → 409,不是 500");
386
+ } finally {
387
+ rmTempBestEffort(notGit);
388
+ }
389
+ });
390
+
391
+ // T1 会真花钱(模型调用),所以这里注入假的 startT1Job / t1JobStatus:
392
+ // 路由层要锁的只是闸门与 200/409/404 的分叉形状,不是进程本身。
393
+ function withT1({ startT1Job, t1JobStatus } = {}) {
394
+ const store = createStore(":memory:");
395
+ const root = makeRepo(store);
396
+ const choro = { ...createChoroQueries({ store }) };
397
+ if (startT1Job) choro.startT1Job = startT1Job;
398
+ if (t1JobStatus) choro.t1JobStatus = t1JobStatus;
399
+ return { store, root, choro };
400
+ }
401
+
402
+ test("/choro/t1:缺装配 503 / 面板闸关 409 / 同仓并发 409 job-running / 缺省 404", async () => {
403
+ const a = withT1({});
404
+ try {
405
+ const noJobs = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: a.choro });
406
+ const missing = await invokePost(handler(noJobs.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
407
+ assert.equal(missing.status, 503, "没装配任务账 → 503 环境缺件");
408
+ assert.equal(missing.body.error, "unavailable");
409
+ } finally {
410
+ rmTempBestEffort(a.root);
411
+ }
412
+
413
+ const b = withT1({ startT1Job: () => ({ started: true, jobId: "j-1" }) });
414
+ try {
415
+ const off = setup({
416
+ config: { chorographyEnabled: true, chorographyT1FromPanel: false },
417
+ choro: b.choro
418
+ });
419
+ const disabled = await invokePost(handler(off.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
420
+ assert.equal(disabled.status, 409, "T1 花模型额度,面板闸关着就不许起");
421
+ assert.equal(disabled.body.error, "disabled");
422
+
423
+ const on = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: b.choro });
424
+ const t1Route = handler(on.routes, "/api/dsh-mneme/choro/t1");
425
+ assert.equal(t1Route.policy, "token-write", "起一个会写页面/claims 的后台任务属数据面写");
426
+
427
+ const ok = await invokePost(t1Route, "/api/dsh-mneme/choro/t1", { repo: 1, module: "src", retryFailed: true });
428
+ assert.equal(ok.status, 200);
429
+ assert.deepEqual(ok.body, { jobId: "j-1", started: true });
430
+
431
+ const unknown = await invokePost(t1Route, "/api/dsh-mneme/choro/t1", { repo: 99 });
432
+ assert.equal(unknown.status, 404);
433
+
434
+ const busy = withT1({ startT1Job: () => ({ started: false, code: "job-running", jobId: "j-2" }) });
435
+ try {
436
+ const busySetup = setup({ config: { chorographyEnabled: true, chorographyT1FromPanel: true }, choro: busy.choro });
437
+ const running = await invokePost(handler(busySetup.routes, "/api/dsh-mneme/choro/t1"), "/api/dsh-mneme/choro/t1", { repo: 1 });
438
+ assert.equal(running.status, 409);
439
+ assert.equal(running.body.error, "job-running");
440
+ assert.equal(running.body.jobId, "j-2", "把在跑的那个 jobId 带回给面板,好让它接着轮询而不是重起");
441
+ } finally {
442
+ rmTempBestEffort(busy.root);
443
+ }
444
+ } finally {
445
+ rmTempBestEffort(b.root);
446
+ }
447
+ });
448
+
449
+ test("/choro/t1/status:jobId 缺 400、未知 404、已知 200 带契约字段", async () => {
450
+ const job = {
451
+ jobId: "j-9",
452
+ repo: { id: 1, name: "x", rootPath: "/x" },
453
+ state: "running",
454
+ startedAt: "T0",
455
+ modules: { total: 2, done: 1, failed: 0 },
456
+ logTail: ["line"]
457
+ };
458
+ const f = withT1({ startT1Job: () => ({ started: true, jobId: "j-9" }), t1JobStatus: (id) => (id === "j-9" ? job : null) });
459
+ try {
460
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro: f.choro });
461
+ const route = handler(routes, "/api/dsh-mneme/choro/t1/status");
462
+
463
+ const noId = await invoke(route, "/api/dsh-mneme/choro/t1/status");
464
+ assert.equal(noId.status, 400);
465
+ assert.equal(noId.body.error, "jobId-required");
466
+
467
+ const gone = await invoke(route, "/api/dsh-mneme/choro/t1/status?jobId=nope");
468
+ assert.equal(gone.status, 404, "宿主重启后旧 jobId 必然查不到,面板据此回到「没有在跑的任务」");
469
+
470
+ const found = await invoke(route, "/api/dsh-mneme/choro/t1/status?jobId=j-9");
471
+ assert.equal(found.status, 200);
472
+ assert.equal(found.body.state, "running");
473
+ assert.equal(found.body.modules.total, 2);
474
+ assert.deepEqual(found.body.logTail, ["line"]);
475
+ } finally {
476
+ rmTempBestEffort(f.root);
477
+ }
478
+ });
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // chorographyAutoRefresh:读侧自动重跑 T0(面板文案已经把它当契约写死了:
482
+ // 「读地志页时如果发现仓库代码变了,自动重跑一次 T0 骨架」)。
483
+ // 这一批同时锁「开关关着绝不偷偷改用户工作区」与「开关开着真的会刷」——只锁一端
484
+ // 的测试在实现成「永远不刷」或「永远刷」时都会绿,等于没锁。
485
+ // ---------------------------------------------------------------------------
486
+
487
+ // 计数外壳:真实 queries + 包一层的 runT0Apply(记调用次数、可注入失败)。
488
+ // 不造一个假 T0——「刷了但基线没推进」这种 bug 只有真 T0 才照得出来。
489
+ function countingChoro(store, { throwOnRun = false } = {}) {
490
+ const calls = [];
491
+ const base = createChoroQueries({ store });
492
+ return {
493
+ calls,
494
+ choro: {
495
+ ...base,
496
+ // guardT1Loss 必须原样透给真 T0:这条守卫的意义就在于「自动刷新那条路会拒绝」,
497
+ // 外壳把它吞掉就等于测了一个不存在的实现。
498
+ runT0Apply: ({ repoRoot, guardT1Loss = false }) => {
499
+ calls.push(repoRoot);
500
+ if (throwOnRun) throw new Error("磁盘只读(模拟)");
501
+ return runChoroT0({ repoRoot, store, apply: true, guardT1Loss });
502
+ }
503
+ }
504
+ };
505
+ }
506
+
507
+ // 造一个「代码已变」的仓库:登记时的 git_baseline 与当前 HEAD 不同。
508
+ function driftRepo(store) {
509
+ const root = makeRepo(store);
510
+ writeFileSync(join(root, "src", "c.js"), "export const c = () => 2;\n", "utf8");
511
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
512
+ execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "drift", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
513
+ return root;
514
+ }
515
+
516
+ test("/choro/repos 自动重跑:开关关着时即使过期也一次都不刷(默认行为逐字节不变)", async () => {
517
+ const store = createStore(":memory:");
518
+ const root = driftRepo(store);
519
+ try {
520
+ const { calls, choro } = countingChoro(store);
521
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro });
522
+ const url = "/api/dsh-mneme/choro/repos?repo=1";
523
+ const REPOS_PATH = "/api/dsh-mneme/choro/repos";
524
+ const res = await invoke(handler(routes, REPOS_PATH), url);
525
+ assert.equal(res.status, 200);
526
+ assert.equal(res.body.repos[0].staleReason, "git", "前提:这个仓库确实是过期的");
527
+ assert.equal(calls.length, 0, "开关缺省 false 时读侧一个字节都不许动用户的工作区");
528
+ assert.equal(res.body.autoRefreshed, false);
529
+ } finally {
530
+ rmTempBestEffort(root);
531
+ }
532
+ });
533
+
534
+ test("/choro/repos 自动重跑:开关开 + ?repo= 点名且 git 过期 → 刷一次,回执按新基线说话", async () => {
535
+ const store = createStore(":memory:");
536
+ const root = driftRepo(store);
537
+ try {
538
+ const { calls, choro } = countingChoro(store);
539
+ const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
540
+ const url = "/api/dsh-mneme/choro/repos?repo=1";
541
+ const REPOS_PATH = "/api/dsh-mneme/choro/repos";
542
+ const res = await invoke(handler(routes, REPOS_PATH), url);
543
+ assert.equal(res.status, 200);
544
+ assert.equal(calls.length, 1, "开关开 + 指名 + git 过期 = 恰好刷这一个仓库");
545
+ assert.equal(res.body.autoRefreshed, true);
546
+ // 刷完必须改成不 stale:否则面板刚刷完就显示「代码已变」,等于谎报一次失败。
547
+ assert.equal(res.body.repos[0].stale, false);
548
+ assert.equal(res.body.repos[0].staleReason, null);
549
+ } finally {
550
+ rmTempBestEffort(root);
551
+ }
552
+ });
553
+
554
+ test("/choro/repos 自动重跑:不 stale 或没点名 ?repo= 时都不刷(一次面板加载不能扫全库)", async () => {
555
+ const store = createStore(":memory:");
556
+ const cleanRoot = makeRepo(store);
557
+ try {
558
+ const { calls, choro } = countingChoro(store);
559
+ const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
560
+ const plain = "/api/dsh-mneme/choro/repos";
561
+ const clean = await invoke(handler(routes, plain), plain);
562
+ assert.equal(clean.status, 200);
563
+ assert.equal(calls.length, 0, "staleReason 为 null → 没有刷的理由");
564
+
565
+ // 造第二个过期仓库,但请求不带 ?repo=:列表路径一个都不许刷。
566
+ const drifted = driftRepo(store);
567
+ try {
568
+ const listed = await invoke(handler(routes, plain), plain);
569
+ assert.equal(listed.body.repos.length, 2);
570
+ assert.equal(calls.length, 0, "没点名就不刷:否则打开面板要付 N 次全仓扫描");
571
+ assert.equal(listed.body.autoRefreshed, false);
572
+ assert.ok(listed.body.repos.some((r) => r.staleReason === "git"), "过期信号照常回,只是不顺手刷");
573
+ } finally {
574
+ rmTempBestEffort(drifted);
575
+ }
576
+ } finally {
577
+ rmTempBestEffort(cleanRoot);
578
+ }
579
+ });
580
+
581
+ test("/choro/repos 自动重跑失败:读照常 200、数据在、错误进 autoRefreshError", async () => {
582
+ const store = createStore(":memory:");
583
+ const root = driftRepo(store);
584
+ try {
585
+ const { calls, choro } = countingChoro(store, { throwOnRun: true });
586
+ const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
587
+ const url = "/api/dsh-mneme/choro/repos?repo=1";
588
+ const REPOS_PATH = "/api/dsh-mneme/choro/repos";
589
+ const res = await invoke(handler(routes, REPOS_PATH), url);
590
+ // 读是主诉求、刷新是附赠:打成 500 等于「顺手的事没办成,正事也不给办」。
591
+ assert.equal(res.status, 200, "刷新失败不许把读请求打成 500");
592
+ assert.equal(calls.length, 1);
593
+ assert.equal(res.body.autoRefreshed, false);
594
+ assert.match(res.body.autoRefreshError, /磁盘只读/);
595
+ assert.equal(res.body.repos.length, 1, "数据必须还在");
596
+ assert.equal(res.body.repos[0].staleReason, "git", "没刷成功就照实说过期");
597
+ } finally {
598
+ rmTempBestEffort(root);
599
+ }
600
+ });
601
+
602
+ // 自动刷新碰上已富化的仓库:不许动手。这条是「静默降级」的正对面——读页本来
603
+ // 只是个浏览动作,却会替用户把语义段和 claims 一起清掉,而屏幕上没有任何提示。
604
+ // 现在它变成一次主动拒绝,且拒绝与失败分开报(autoRefreshSkipped vs autoRefreshError)。
605
+ test("/choro/repos 自动重跑:仓库里有富化页时主动跳过,不动字节、不说成失败", async () => {
606
+ const store = createStore(":memory:");
607
+ const root = driftRepo(store);
608
+ try {
609
+ const repo = store.choroRepoByRoot(root);
610
+ const page = store.choroPageBySlug(repo.id, "src");
611
+ store.markChoroPageEnriched(page.id, "2026-11-01T00:00:00.000Z", null);
612
+ const pagePath = join(root, page.rel_path);
613
+ writeFileSync(pagePath, `${readFileSync(pagePath, "utf8")}\n<!-- CHORO:T1:START -->\n## 架构\n\na.js 是入口。\n<!-- CHORO:T1:END -->\n`, "utf8");
614
+ const before = readFileSync(pagePath, "utf8");
615
+
616
+ const { calls, choro } = countingChoro(store);
617
+ const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
618
+ const REPOS_PATH = "/api/dsh-mneme/choro/repos";
619
+ const res = await invoke(handler(routes, REPOS_PATH), `${REPOS_PATH}?repo=1`);
620
+
621
+ assert.equal(res.status, 200);
622
+ assert.equal(calls.length, 0, "有富化页时连 T0 都不该被叫起来");
623
+ assert.equal(res.body.autoRefreshed, false);
624
+ assert.equal(res.body.autoRefreshSkipped, "t1-loss", "跳过要和失败分开报");
625
+ assert.equal(res.body.autoRefreshError, undefined, "主动拒绝不是错误");
626
+ assert.match(res.body.autoRefreshSkipNote, /1 页/, "说清为什么没做");
627
+ assert.equal(readFileSync(pagePath, "utf8"), before, "页一字节未动");
628
+ assert.equal(res.body.repos[0].staleReason, "git", "没刷就照实说过期");
629
+ assert.equal(res.body.repos[0].enrichedPages, 1, "面板要靠这个数解释原因");
630
+ } finally {
631
+ store.close();
632
+ rmTempBestEffort(root);
633
+ }
634
+ });
635
+
636
+ // 库里的账和磁盘对不上时(status 还是 skeleton、页里却有 T1 段),T0 的读盘判断
637
+ // 会抛 would-drop-t1。到这一层仍按「主动跳过」回,绝不报成刷新失败。
638
+ test("/choro/page 自动重跑:T0 拒绝(would-drop-t1)时回 skipped 而非 error", async () => {
639
+ const store = createStore(":memory:");
640
+ const root = driftRepo(store);
641
+ try {
642
+ const repo = store.choroRepoByRoot(root);
643
+ const page = store.choroPageBySlug(repo.id, "src");
644
+ const pagePath = join(root, page.rel_path);
645
+ // 故意不改库里的 status:模拟「有人手改了 MD」这类账实不符。
646
+ writeFileSync(pagePath, `${readFileSync(pagePath, "utf8")}\n<!-- CHORO:T1:START -->\n## 架构\n\na.js 是入口。\n<!-- CHORO:T1:END -->\n`, "utf8");
647
+ const before = readFileSync(pagePath, "utf8");
648
+
649
+ const { choro } = countingChoro(store);
650
+ const { routes } = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
651
+ const PAGE_PATH = "/api/dsh-mneme/choro/page";
652
+ const res = await invoke(handler(routes, PAGE_PATH), `${PAGE_PATH}?repo=1&slug=src`);
653
+
654
+ assert.equal(res.status, 200);
655
+ assert.equal(res.body.autoRefreshed, false);
656
+ assert.equal(res.body.autoRefreshSkipped, "t1-loss");
657
+ assert.equal(res.body.autoRefreshError, undefined);
658
+ assert.match(res.body.autoRefreshSkipNote, /富化/, "T0 那句人话原样透出去");
659
+ assert.equal(readFileSync(pagePath, "utf8"), before, "拒绝发生在写盘之前");
660
+ assert.ok(res.body.page.content.includes("CHORO:T1:START"), "页正文照常回,别把读也带坏");
661
+ } finally {
662
+ store.close();
663
+ rmTempBestEffort(root);
664
+ }
665
+ });
666
+
667
+ // /choro/page 是面板主路径,也是「读地志页时发现代码变了」那句文案的真正时刻。
668
+ // 顶栏「代码已变」徽章读 state.repo.stale —— 回执里没有 repo 这份数据,徽章永远不亮。
669
+ test("/choro/page 回执带 repo.stale/staleReason,并在这条读路径上自动重跑", async () => {
670
+ const store = createStore(":memory:");
671
+ const root = driftRepo(store);
672
+ try {
673
+ const { calls, choro } = countingChoro(store);
674
+ const url = "/api/dsh-mneme/choro/page?repo=1&slug=src";
675
+ const PAGE_PATH = "/api/dsh-mneme/choro/page";
676
+
677
+ const offSetup = setup({ config: { chorographyEnabled: true }, choro });
678
+ const off = await invoke(handler(offSetup.routes, PAGE_PATH), url);
679
+ assert.equal(off.status, 200);
680
+ assert.equal(off.body.repo.stale, true, "徽章的数据源:开关关着也要照实报过期");
681
+ assert.equal(off.body.repo.staleReason, "git");
682
+ assert.equal(off.body.autoRefreshed, false);
683
+ assert.equal(calls.length, 0);
684
+
685
+ const onSetup = setup({ config: { chorographyEnabled: true, chorographyAutoRefresh: true }, choro });
686
+ const on = await invoke(handler(onSetup.routes, PAGE_PATH), url);
687
+ assert.equal(on.status, 200);
688
+ assert.equal(calls.length, 1, "页读路径点名的就是这一个仓库");
689
+ assert.equal(on.body.autoRefreshed, true);
690
+ assert.equal(on.body.repo.stale, false, "刷完页面与 repo 徽章必须同一时刻一致");
691
+ assert.ok(on.body.page.content.includes("## 文件清单"), "页正文照常回");
692
+ } finally {
693
+ rmTempBestEffort(root);
694
+ }
695
+ });
696
+ // --- /choro/claims(claim 粒度失效,Grounded Claims)-----------------------
697
+ // 锁的是一条态度:算不出来 ≠ 没事。基线认不出时必须 anchorStale,不许回一张空清单
698
+ // 让面板显示「全部结论都还准」——那是 #607 同类的假成功。
699
+
700
+ test("/choro/claims 在册且 policy open,四层闸与其余读路由一致", async () => {
701
+ const off = setup({});
702
+ const route = handler(off.routes, "/api/dsh-mneme/choro/claims");
703
+ assert.ok(route, "/choro/claims 在册");
704
+ assert.equal(route.policy, "open");
705
+ const a = await invoke(route, "/api/dsh-mneme/choro/claims");
706
+ assert.equal(a.status, 503, "queries 缺装配 → 503");
707
+
708
+ const disabled = setup({ config: { chorographyEnabled: false }, choro: createChoroQueries({ store: createStore(":memory:") }) });
709
+ const b = await invoke(handler(disabled.routes, "/api/dsh-mneme/choro/claims"), "/api/dsh-mneme/choro/claims");
710
+ assert.equal(b.status, 409, "开关关 → 409");
711
+ assert.equal(b.body.error, "disabled");
712
+ });
713
+
714
+ test("/choro/claims:快乐路径带页清单与 totals;未知 repo 404", async () => {
715
+ const store = createStore(":memory:");
716
+ const root = makeRepo(store);
717
+ try {
718
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
719
+ const route = handler(routes, "/api/dsh-mneme/choro/claims");
720
+ const repoId = store.db.prepare("SELECT id FROM choro_repos LIMIT 1").get().id;
721
+ const r = await invoke(route, `/api/dsh-mneme/choro/claims?repo=${repoId}`);
722
+ assert.equal(r.status, 200);
723
+ assert.ok(r.body.repo && r.body.repo.id === repoId);
724
+ assert.equal(typeof r.body.head, "string");
725
+ assert.ok(Array.isArray(r.body.pages), "页清单要在——面板靠它说「这页没问题」");
726
+ assert.ok(r.body.totals && Number.isInteger(r.body.totals.claims));
727
+ assert.equal(r.body.clean, true, "刚跑完 T0,基线就是 HEAD");
728
+
729
+ const missing = await invoke(route, "/api/dsh-mneme/choro/claims?repo=999999");
730
+ assert.equal(missing.status, 404);
731
+ } finally {
732
+ store.close();
733
+ rmTempBestEffort(root);
734
+ }
628
735
  });