@indigoai-us/hq-cloud 6.15.0 → 6.15.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 (329) hide show
  1. package/dist/bin/sync-mutation.d.ts +16 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +165 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/sync.d.ts +35 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +100 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +85 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skill-telemetry.d.ts +6 -0
  44. package/dist/skill-telemetry.d.ts.map +1 -1
  45. package/dist/skill-telemetry.js +14 -2
  46. package/dist/skill-telemetry.js.map +1 -1
  47. package/dist/skill-telemetry.test.js +79 -0
  48. package/dist/skill-telemetry.test.js.map +1 -1
  49. package/dist/sync/candidate-uploader.d.ts +88 -0
  50. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  51. package/dist/sync/candidate-uploader.js +212 -0
  52. package/dist/sync/candidate-uploader.js.map +1 -0
  53. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  54. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  55. package/dist/sync/candidate-uploader.test.js +132 -0
  56. package/dist/sync/candidate-uploader.test.js.map +1 -0
  57. package/dist/sync/delta-client.d.ts +73 -0
  58. package/dist/sync/delta-client.d.ts.map +1 -0
  59. package/dist/sync/delta-client.js +201 -0
  60. package/dist/sync/delta-client.js.map +1 -0
  61. package/dist/sync/delta-client.test.d.ts +2 -0
  62. package/dist/sync/delta-client.test.d.ts.map +1 -0
  63. package/dist/sync/delta-client.test.js +97 -0
  64. package/dist/sync/delta-client.test.js.map +1 -0
  65. package/dist/sync/durable-apply.d.ts +76 -0
  66. package/dist/sync/durable-apply.d.ts.map +1 -0
  67. package/dist/sync/durable-apply.js +530 -0
  68. package/dist/sync/durable-apply.js.map +1 -0
  69. package/dist/sync/durable-apply.test.d.ts +2 -0
  70. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  71. package/dist/sync/durable-apply.test.js +180 -0
  72. package/dist/sync/durable-apply.test.js.map +1 -0
  73. package/dist/sync/event-sync.d.ts +33 -1
  74. package/dist/sync/event-sync.d.ts.map +1 -1
  75. package/dist/sync/event-sync.js +149 -1
  76. package/dist/sync/event-sync.js.map +1 -1
  77. package/dist/sync/event-sync.test.js +142 -1
  78. package/dist/sync/event-sync.test.js.map +1 -1
  79. package/dist/sync/index.d.ts +2 -0
  80. package/dist/sync/index.d.ts.map +1 -1
  81. package/dist/sync/index.js +1 -0
  82. package/dist/sync/index.js.map +1 -1
  83. package/dist/sync/multipart-uploader.d.ts +99 -0
  84. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  85. package/dist/sync/multipart-uploader.js +447 -0
  86. package/dist/sync/multipart-uploader.js.map +1 -0
  87. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  88. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  89. package/dist/sync/multipart-uploader.test.js +119 -0
  90. package/dist/sync/multipart-uploader.test.js.map +1 -0
  91. package/dist/sync/mutation-client.d.ts +85 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +245 -0
  94. package/dist/sync/mutation-client.js.map +1 -0
  95. package/dist/sync/mutation-client.test.d.ts +2 -0
  96. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  97. package/dist/sync/mutation-client.test.js +51 -0
  98. package/dist/sync/mutation-client.test.js.map +1 -0
  99. package/dist/sync/push-receiver.d.ts +45 -0
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +101 -0
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +54 -2
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/sync/scope-inventory-client.d.ts +69 -0
  106. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  107. package/dist/sync/scope-inventory-client.js +210 -0
  108. package/dist/sync/scope-inventory-client.js.map +1 -0
  109. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  110. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  111. package/dist/sync/scope-inventory-client.test.js +94 -0
  112. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  113. package/dist/sync/snapshot-client.d.ts +98 -0
  114. package/dist/sync/snapshot-client.d.ts.map +1 -0
  115. package/dist/sync/snapshot-client.js +402 -0
  116. package/dist/sync/snapshot-client.js.map +1 -0
  117. package/dist/sync/snapshot-client.test.d.ts +2 -0
  118. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  119. package/dist/sync/snapshot-client.test.js +169 -0
  120. package/dist/sync/snapshot-client.test.js.map +1 -0
  121. package/dist/sync/uploader-finalization.d.ts +97 -0
  122. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  123. package/dist/sync/uploader-finalization.js +273 -0
  124. package/dist/sync/uploader-finalization.js.map +1 -0
  125. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  126. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  127. package/dist/sync/uploader-finalization.test.js +92 -0
  128. package/dist/sync/uploader-finalization.test.js.map +1 -0
  129. package/dist/telemetry.d.ts +11 -1
  130. package/dist/telemetry.d.ts.map +1 -1
  131. package/dist/telemetry.js +21 -2
  132. package/dist/telemetry.js.map +1 -1
  133. package/dist/telemetry.test.js +80 -0
  134. package/dist/telemetry.test.js.map +1 -1
  135. package/package.json +6 -1
  136. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  137. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  138. package/.github/workflows/ci.yml +0 -84
  139. package/.github/workflows/publish.yml +0 -56
  140. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  141. package/eslint.config.js +0 -67
  142. package/pnpm-workspace.yaml +0 -2
  143. package/scripts/presign-transport-e2e.mjs +0 -250
  144. package/scripts/vault-rebaseline.sh +0 -323
  145. package/scripts/vault-rescue.sh +0 -332
  146. package/src/active-company.test.ts +0 -188
  147. package/src/active-company.ts +0 -168
  148. package/src/agent-codex-instructions.test.ts +0 -332
  149. package/src/agent-codex-instructions.ts +0 -309
  150. package/src/auth.ts +0 -146
  151. package/src/backup-prune.test.ts +0 -98
  152. package/src/backup-prune.ts +0 -182
  153. package/src/bin/backup-prune-runner.ts +0 -33
  154. package/src/bin/rescue-runner.ts +0 -25
  155. package/src/bin/sync-runner-company.ts +0 -695
  156. package/src/bin/sync-runner-events.test.ts +0 -143
  157. package/src/bin/sync-runner-events.ts +0 -55
  158. package/src/bin/sync-runner-planning.test.ts +0 -311
  159. package/src/bin/sync-runner-planning.ts +0 -258
  160. package/src/bin/sync-runner-rollup.test.ts +0 -37
  161. package/src/bin/sync-runner-rollup.ts +0 -97
  162. package/src/bin/sync-runner-telemetry.ts +0 -15
  163. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  164. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  165. package/src/bin/sync-runner-watch-routes.ts +0 -184
  166. package/src/bin/sync-runner.test.ts +0 -8767
  167. package/src/bin/sync-runner.ts +0 -2190
  168. package/src/cli/accept.ts +0 -124
  169. package/src/cli/conflict.ts +0 -119
  170. package/src/cli/doctor.test.ts +0 -581
  171. package/src/cli/doctor.ts +0 -642
  172. package/src/cli/index.ts +0 -49
  173. package/src/cli/invite.test.ts +0 -250
  174. package/src/cli/invite.ts +0 -214
  175. package/src/cli/promote.ts +0 -157
  176. package/src/cli/reindex-knowledge.test.ts +0 -307
  177. package/src/cli/reindex-knowledge.ts +0 -450
  178. package/src/cli/reindex.test.ts +0 -957
  179. package/src/cli/reindex.ts +0 -979
  180. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  181. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  182. package/src/cli/rescue-core.ts +0 -3011
  183. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  184. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  185. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  186. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  187. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  188. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  189. package/src/cli/rescue-settings-reconcile.test.ts +0 -637
  190. package/src/cli/rescue-snapshot.test.ts +0 -57
  191. package/src/cli/rescue-snapshot.ts +0 -51
  192. package/src/cli/rescue.reindex.test.ts +0 -63
  193. package/src/cli/rescue.test.ts +0 -131
  194. package/src/cli/rescue.ts +0 -182
  195. package/src/cli/share.test.ts +0 -7843
  196. package/src/cli/share.ts +0 -3663
  197. package/src/cli/sync-scope.test.ts +0 -652
  198. package/src/cli/sync.test.ts +0 -5207
  199. package/src/cli/sync.ts +0 -3470
  200. package/src/cli/tombstones.ts +0 -106
  201. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  202. package/src/client-info.test.ts +0 -214
  203. package/src/client-info.ts +0 -121
  204. package/src/cognito-auth.test.ts +0 -712
  205. package/src/cognito-auth.ts +0 -1422
  206. package/src/company-resolver.test.ts +0 -618
  207. package/src/company-resolver.ts +0 -521
  208. package/src/context.test.ts +0 -583
  209. package/src/context.ts +0 -378
  210. package/src/daemon-worker.ts +0 -26
  211. package/src/daemon.ts +0 -99
  212. package/src/entity-resolver.test.ts +0 -315
  213. package/src/entity-resolver.ts +0 -180
  214. package/src/ignore.test.ts +0 -466
  215. package/src/ignore.ts +0 -469
  216. package/src/index.ts +0 -439
  217. package/src/journal.test.ts +0 -968
  218. package/src/journal.ts +0 -765
  219. package/src/lib/cloud-authoritative.test.ts +0 -45
  220. package/src/lib/cloud-authoritative.ts +0 -59
  221. package/src/lib/conflict-file.ts +0 -86
  222. package/src/lib/conflict-index.ts +0 -289
  223. package/src/lib/conflict.test.ts +0 -348
  224. package/src/lib/describe-error.test.ts +0 -100
  225. package/src/lib/describe-error.ts +0 -58
  226. package/src/lib/exit-codes.ts +0 -24
  227. package/src/lib/machine-id.test.ts +0 -231
  228. package/src/lib/machine-id.ts +0 -175
  229. package/src/lib/net-errors.test.ts +0 -65
  230. package/src/lib/net-errors.ts +0 -86
  231. package/src/lib/readlink-safe.test.ts +0 -43
  232. package/src/lib/readlink-safe.ts +0 -29
  233. package/src/local-path-codec.test.ts +0 -138
  234. package/src/local-path-codec.ts +0 -161
  235. package/src/machine-auth.test.ts +0 -1323
  236. package/src/manifest-reconcile.test.ts +0 -1123
  237. package/src/manifest-reconcile.ts +0 -518
  238. package/src/object-io.test.ts +0 -1221
  239. package/src/object-io.ts +0 -1306
  240. package/src/operation-lock.test.ts +0 -484
  241. package/src/operation-lock.ts +0 -680
  242. package/src/outcome-telemetry.test.ts +0 -498
  243. package/src/outcome-telemetry.ts +0 -639
  244. package/src/personal-vault-exclusions.test.ts +0 -308
  245. package/src/personal-vault-exclusions.ts +0 -354
  246. package/src/personal-vault.test.ts +0 -756
  247. package/src/personal-vault.ts +0 -496
  248. package/src/prefix-coalesce.test.ts +0 -240
  249. package/src/prefix-coalesce.ts +0 -273
  250. package/src/public-surface.test.ts +0 -117
  251. package/src/qmd-reindex.test.ts +0 -877
  252. package/src/qmd-reindex.ts +0 -842
  253. package/src/read-only-state-dir.test.ts +0 -188
  254. package/src/remote-pull.test.ts +0 -1130
  255. package/src/remote-pull.ts +0 -618
  256. package/src/s3.symlink-materialize.test.ts +0 -492
  257. package/src/s3.test.ts +0 -1789
  258. package/src/s3.ts +0 -1532
  259. package/src/schemas/signal-types.test.ts +0 -82
  260. package/src/schemas/signal-types.ts +0 -38
  261. package/src/schemas/source-channels.test.ts +0 -82
  262. package/src/schemas/source-channels.ts +0 -53
  263. package/src/scope-shrink.test.ts +0 -633
  264. package/src/scope-shrink.ts +0 -481
  265. package/src/signals/get.test.ts +0 -310
  266. package/src/signals/get.ts +0 -75
  267. package/src/signals/internals.ts +0 -195
  268. package/src/signals/list.test.ts +0 -420
  269. package/src/signals/list.ts +0 -79
  270. package/src/signals/parse.ts +0 -8
  271. package/src/signals/types.ts +0 -91
  272. package/src/skill-telemetry.test.ts +0 -1825
  273. package/src/skill-telemetry.ts +0 -1439
  274. package/src/sources/get.test.ts +0 -293
  275. package/src/sources/get.ts +0 -66
  276. package/src/sources/internals.ts +0 -198
  277. package/src/sources/list.test.ts +0 -402
  278. package/src/sources/list.ts +0 -84
  279. package/src/sources/parse.ts +0 -43
  280. package/src/sources/types.ts +0 -84
  281. package/src/sync/event-sync.test.ts +0 -594
  282. package/src/sync/event-sync.ts +0 -545
  283. package/src/sync/feature-flags.test.ts +0 -378
  284. package/src/sync/feature-flags.ts +0 -62
  285. package/src/sync/index.ts +0 -76
  286. package/src/sync/lease-client.test.ts +0 -128
  287. package/src/sync/lease-client.ts +0 -207
  288. package/src/sync/logger.test.ts +0 -242
  289. package/src/sync/logger.ts +0 -79
  290. package/src/sync/metrics.test.ts +0 -462
  291. package/src/sync/metrics.ts +0 -213
  292. package/src/sync/pull-scope.ts +0 -265
  293. package/src/sync/push-event.test.ts +0 -266
  294. package/src/sync/push-event.ts +0 -224
  295. package/src/sync/push-receiver.test.ts +0 -566
  296. package/src/sync/push-receiver.ts +0 -1048
  297. package/src/sync/push-transport.ts +0 -231
  298. package/src/sync/realtime-rollout.test.ts +0 -86
  299. package/src/sync/realtime-rollout.ts +0 -262
  300. package/src/sync/state-store.test.ts +0 -194
  301. package/src/sync/state-store.ts +0 -727
  302. package/src/sync-core.ts +0 -58
  303. package/src/sync-progress.test.ts +0 -94
  304. package/src/sync-progress.ts +0 -140
  305. package/src/telemetry-events.test.ts +0 -88
  306. package/src/telemetry-events.ts +0 -205
  307. package/src/telemetry.test.ts +0 -1280
  308. package/src/telemetry.ts +0 -1109
  309. package/src/types.ts +0 -314
  310. package/src/vault-client.test.ts +0 -1380
  311. package/src/vault-client.ts +0 -1694
  312. package/src/version.ts +0 -24
  313. package/src/watch-roots.test.ts +0 -278
  314. package/src/watch-roots.ts +0 -162
  315. package/src/watcher-event-gate.test.ts +0 -212
  316. package/src/watcher.test.ts +0 -1079
  317. package/src/watcher.ts +0 -1741
  318. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  319. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  320. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  321. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  322. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  323. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  324. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  325. package/test/invite-flow.integration.test.ts +0 -244
  326. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  327. package/test/share-sync.integration.test.ts +0 -213
  328. package/tsconfig.json +0 -19
  329. package/vitest.config.ts +0 -22
@@ -1,652 +0,0 @@
1
- /**
2
- * Unit tests for scope-aware download (US-005 wiring into `sync()`).
3
- *
4
- * Covers the contract added when `syncMode` / `prefixSet` were threaded
5
- * through `computePullPlan` + the scope-shrink pass:
6
- *
7
- * - `all` → no filtering (regression guard lives in sync.test.ts).
8
- * - `shared` → only keys covered by `prefixSet` download; the rest are
9
- * classified `skip-out-of-scope` (NOT downloaded).
10
- * - `custom` → same mechanism, driven by the explicit path list.
11
- * - Idempotency → a second `shared` pull downloads nothing and removes
12
- * nothing (the PullRecord makes scope-change a no-op).
13
- * - Scope shrink → narrowing `all → shared` prunes the now-out-of-scope
14
- * CLEAN local orphan; a DIRTY orphan aborts with
15
- * `ScopeShrinkBlockedError` unless `forceScopeShrink`.
16
- *
17
- * The security contract (this filter is footprint-only, never an authz
18
- * boundary) is asserted indirectly: out-of-scope keys are still LISTED and
19
- * accessible — the engine simply chooses not to materialize them.
20
- */
21
-
22
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
23
- import * as fs from "fs";
24
- import * as path from "path";
25
- import * as os from "os";
26
- import { clearContextCache } from "../context.js";
27
- import type { VaultServiceConfig } from "../types.js";
28
-
29
- // Mutable remote-file list so each test controls what the vault returns.
30
- const REMOTE: { current: Array<{ key: string; size: number; lastModified: Date; etag: string }> } = {
31
- current: [
32
- { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
33
- { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
34
- ],
35
- };
36
-
37
- vi.mock("../s3.js", async (importOriginal) => {
38
- const actual = await importOriginal<typeof import("../s3.js")>();
39
- const innerFs = await import("fs");
40
- const innerPath = await import("path");
41
- const { vi: innerVi } = await import("vitest");
42
- return {
43
- ...actual,
44
- toPosixKey: (key: string) => key.split("\\").join("/"),
45
- uploadFile: innerVi.fn().mockResolvedValue(undefined),
46
- downloadFile: innerVi
47
- .fn()
48
- .mockImplementation(async (_ctx: unknown, key: string, localPath: string) => {
49
- const dir = innerPath.dirname(localPath);
50
- if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
51
- // Deterministic per-key body so re-downloads produce a stable hash.
52
- innerFs.writeFileSync(localPath, `mock:${key}`);
53
- // Real GETs carry author metadata; surface a fixed uploader sub so
54
- // journal entries are stamped with a KNOWN author. With no `callerSub`
55
- // passed (the default for these tests), that author is "foreign", so
56
- // the scope-shrink prune/block paths behave exactly as pre-guard —
57
- // only the new own-author retention test passes a matching callerSub.
58
- return { metadata: { "created-by-sub": "uploader-sub" } };
59
- }),
60
- listRemoteFiles: innerVi.fn().mockImplementation(async () => REMOTE.current),
61
- deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
62
- // HEAD returns metadata (object exists) for any key still in REMOTE,
63
- // null otherwise — mirrors the real bucket so the tombstone HEAD-verify
64
- // pass behaves correctly for out-of-scope (still-present) keys.
65
- headRemoteFile: innerVi.fn().mockImplementation(async (_ctx: unknown, key: string) => {
66
- const hit = REMOTE.current.find((r) => r.key === key);
67
- return hit ? { metadata: {}, size: hit.size, etag: hit.etag } : null;
68
- }),
69
- primeObjectTransport: innerVi.fn().mockResolvedValue(undefined),
70
- };
71
- });
72
-
73
- import { sync } from "./sync.js";
74
- import {
75
- ScopeShrinkBlockedError,
76
- ScopeShrinkLargePruneError,
77
- } from "../scope-shrink.js";
78
-
79
- const mockConfig: VaultServiceConfig = {
80
- apiUrl: "https://vault-api.test",
81
- authToken: "test-jwt-token",
82
- region: "us-east-1",
83
- };
84
-
85
- const mockEntity = {
86
- uid: "cmp_01ABCDEF",
87
- slug: "acme",
88
- bucketName: "hq-vault-acme-123",
89
- status: "active",
90
- };
91
-
92
- const mockVendResponse = {
93
- credentials: {
94
- accessKeyId: "ASIA_TEST_KEY",
95
- secretAccessKey: "test-secret",
96
- sessionToken: "test-session-token",
97
- expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
98
- },
99
- expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
100
- };
101
-
102
- function setupFetchMock() {
103
- const fetchMock = vi.fn().mockImplementation(async (url: string) => {
104
- const urlStr = String(url);
105
- if (urlStr.includes("/entity/check-slug/me")) {
106
- return {
107
- ok: true,
108
- status: 200,
109
- json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
110
- text: async () => "",
111
- };
112
- }
113
- if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
114
- return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
115
- }
116
- if (urlStr.includes("/sts/vend")) {
117
- return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
118
- }
119
- return { ok: false, status: 404, text: async () => "Not found" };
120
- });
121
- vi.stubGlobal("fetch", fetchMock);
122
- return fetchMock;
123
- }
124
-
125
- describe("sync — scope-aware download (US-005)", () => {
126
- let tmpDir: string;
127
- let stateDir: string;
128
- const companyRel = (p: string) => path.join(tmpDir, "companies", "acme", p);
129
-
130
- beforeEach(() => {
131
- clearContextCache();
132
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-scope-"));
133
- stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-scope-"));
134
- process.env.HQ_STATE_DIR = stateDir;
135
- // Reset the remote list every test (a prior test may have mutated it).
136
- REMOTE.current = [
137
- { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
138
- { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
139
- ];
140
- setupFetchMock();
141
- });
142
-
143
- afterEach(() => {
144
- vi.unstubAllGlobals();
145
- vi.clearAllMocks();
146
- fs.rmSync(tmpDir, { recursive: true, force: true });
147
- fs.rmSync(stateDir, { recursive: true, force: true });
148
- delete process.env.HQ_STATE_DIR;
149
- });
150
-
151
- it("shared mode downloads only keys covered by prefixSet; rest are skip-out-of-scope", async () => {
152
- const result = await sync({
153
- company: "acme",
154
- vaultConfig: mockConfig,
155
- hqRoot: tmpDir,
156
- syncMode: "shared",
157
- prefixSet: ["knowledge/"],
158
- });
159
-
160
- expect(result.filesDownloaded).toBe(1);
161
- expect(result.filesOutOfScope).toBe(1);
162
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
163
- // docs/handoff.md is out of scope — never materialized.
164
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
165
- });
166
-
167
- it("custom mode behaves like shared, driven by the explicit prefix list", async () => {
168
- const result = await sync({
169
- company: "acme",
170
- vaultConfig: mockConfig,
171
- hqRoot: tmpDir,
172
- syncMode: "custom",
173
- prefixSet: ["docs/"],
174
- });
175
-
176
- expect(result.filesDownloaded).toBe(1);
177
- expect(result.filesOutOfScope).toBe(1);
178
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
179
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
180
- });
181
-
182
- it("shared mode with empty prefixSet downloads nothing", async () => {
183
- const result = await sync({
184
- company: "acme",
185
- vaultConfig: mockConfig,
186
- hqRoot: tmpDir,
187
- syncMode: "shared",
188
- prefixSet: [],
189
- });
190
-
191
- expect(result.filesDownloaded).toBe(0);
192
- expect(result.filesOutOfScope).toBe(2);
193
- });
194
-
195
- // FILE_TOMBSTONE fetch is COMPANY-scoped (GET /v1/files/tombstones?company=…).
196
- // For the personal vault the target uid is a personUid (prs_…), which the
197
- // server correctly 403s ("No active membership for caller in company prs_…"),
198
- // spamming hq-pro's Sentry with a per-user no-membership cluster. The pull
199
- // must SKIP the fetch for the personal target (delete-resync was never a
200
- // committed personal-vault feature). Differential: company-mode fetches,
201
- // personal-mode does not.
202
- const hitTombstones = (m: ReturnType<typeof setupFetchMock>) =>
203
- m.mock.calls.some((c: unknown[]) =>
204
- String(c[0]).includes("/v1/files/tombstones"),
205
- );
206
-
207
- it("company-mode pull fetches FILE_TOMBSTONEs (baseline for the personal guard)", async () => {
208
- const fetchMock = setupFetchMock();
209
- await sync({
210
- company: "acme",
211
- vaultConfig: mockConfig,
212
- hqRoot: tmpDir,
213
- syncMode: "all",
214
- prefixSet: [],
215
- });
216
- expect(hitTombstones(fetchMock)).toBe(true);
217
- });
218
-
219
- it("personal-vault pull SKIPS the company-scoped tombstone fetch (no 403 → no Sentry no-membership spam)", async () => {
220
- const fetchMock = setupFetchMock();
221
- await sync({
222
- company: "acme",
223
- vaultConfig: mockConfig,
224
- hqRoot: tmpDir,
225
- syncMode: "all",
226
- prefixSet: [],
227
- personalMode: true,
228
- });
229
- expect(hitTombstones(fetchMock)).toBe(false);
230
- });
231
-
232
- it("is idempotent: a second shared pull downloads and removes nothing", async () => {
233
- const opts = {
234
- company: "acme",
235
- vaultConfig: mockConfig,
236
- hqRoot: tmpDir,
237
- syncMode: "shared" as const,
238
- prefixSet: ["knowledge/"],
239
- };
240
- const first = await sync(opts);
241
- expect(first.filesDownloaded).toBe(1);
242
-
243
- const second = await sync(opts);
244
- expect(second.filesDownloaded).toBe(0);
245
- expect(second.scopeOrphansRemoved).toBe(0);
246
- expect(second.scopeOrphansBlocked).toBe(0);
247
- // knowledge file still present; nothing churned.
248
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
249
- });
250
-
251
- it("scope shrink (all → shared) prunes the clean out-of-scope orphan", async () => {
252
- // First pull EVERYTHING under all-mode.
253
- const all = await sync({
254
- company: "acme",
255
- vaultConfig: mockConfig,
256
- hqRoot: tmpDir,
257
- syncMode: "all",
258
- });
259
- expect(all.filesDownloaded).toBe(2);
260
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
261
-
262
- // Narrow to shared/knowledge → docs/handoff.md is now a clean orphan.
263
- const shared = await sync({
264
- company: "acme",
265
- vaultConfig: mockConfig,
266
- hqRoot: tmpDir,
267
- syncMode: "shared",
268
- prefixSet: ["knowledge/"],
269
- });
270
- expect(shared.scopeOrphansRemoved).toBe(1);
271
- expect(shared.scopeOrphansBlocked).toBe(0);
272
- // The clean orphan was pruned; the in-scope file stays.
273
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
274
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
275
- });
276
-
277
- it("scope shrink (all → shared) RETAINS the owner's own out-of-scope file", async () => {
278
- // Corey's exact scenario: the caller authored everything (the mock stamps
279
- // `created-by-sub: "uploader-sub"`), so passing the matching `callerSub`
280
- // means narrowing to `shared` must NOT prune their own work — mode only
281
- // governs mirroring OTHER people's files.
282
- const all = await sync({
283
- company: "acme",
284
- vaultConfig: mockConfig,
285
- hqRoot: tmpDir,
286
- syncMode: "all",
287
- callerSub: "uploader-sub",
288
- });
289
- expect(all.filesDownloaded).toBe(2);
290
-
291
- const shared = await sync({
292
- company: "acme",
293
- vaultConfig: mockConfig,
294
- hqRoot: tmpDir,
295
- syncMode: "shared",
296
- prefixSet: ["knowledge/"],
297
- callerSub: "uploader-sub",
298
- });
299
- // Out of scope, but authored by the caller → retained, not pruned.
300
- expect(shared.scopeOrphansRemoved).toBe(0);
301
- expect(shared.scopeOrphansBlocked).toBe(0);
302
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
303
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
304
- });
305
-
306
- it("refuses a bulk auto-prune over the safety cap, then proceeds when forced", async () => {
307
- // Pull both files under all-mode, then narrow to a scope covering neither
308
- // → 2 clean orphans. With the cap set to 1, the auto-prune is refused.
309
- await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
310
-
311
- // Narrow to a scope covering neither file → 2 clean orphans; cap at 1.
312
- process.env.HQ_SYNC_MAX_AUTO_PRUNE = "1";
313
- try {
314
- await expect(
315
- sync({
316
- company: "acme",
317
- vaultConfig: mockConfig,
318
- hqRoot: tmpDir,
319
- syncMode: "shared",
320
- prefixSet: ["nonexistent/"],
321
- }),
322
- ).rejects.toBeInstanceOf(ScopeShrinkLargePruneError);
323
- // Nothing deleted on the refused run.
324
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
325
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
326
-
327
- // Forced: the prune proceeds despite the cap.
328
- const forced = await sync({
329
- company: "acme",
330
- vaultConfig: mockConfig,
331
- hqRoot: tmpDir,
332
- syncMode: "shared",
333
- prefixSet: ["nonexistent/"],
334
- forceScopeShrink: true,
335
- });
336
- expect(forced.scopeOrphansRemoved).toBe(2);
337
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
338
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
339
- } finally {
340
- delete process.env.HQ_SYNC_MAX_AUTO_PRUNE;
341
- }
342
- });
343
-
344
- it("scope shrink aborts on a DIRTY orphan unless forceScopeShrink is set", async () => {
345
- await sync({
346
- company: "acme",
347
- vaultConfig: mockConfig,
348
- hqRoot: tmpDir,
349
- syncMode: "all",
350
- });
351
- // Locally modify the soon-to-be-orphan so it's dirty (hash mismatch).
352
- fs.writeFileSync(companyRel("docs/handoff.md"), "LOCAL EDIT — do not delete");
353
-
354
- // Default: abort with the structured error; the dirty file is untouched.
355
- await expect(
356
- sync({
357
- company: "acme",
358
- vaultConfig: mockConfig,
359
- hqRoot: tmpDir,
360
- syncMode: "shared",
361
- prefixSet: ["knowledge/"],
362
- }),
363
- ).rejects.toBeInstanceOf(ScopeShrinkBlockedError);
364
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
365
- expect(fs.readFileSync(companyRel("docs/handoff.md"), "utf-8")).toBe(
366
- "LOCAL EDIT — do not delete",
367
- );
368
-
369
- // With force: the leg proceeds, the dirty file is LEFT ON DISK, only its
370
- // journal entry is tombstoned.
371
- const forced = await sync({
372
- company: "acme",
373
- vaultConfig: mockConfig,
374
- hqRoot: tmpDir,
375
- syncMode: "shared",
376
- prefixSet: ["knowledge/"],
377
- forceScopeShrink: true,
378
- });
379
- expect(forced.scopeOrphansBlocked).toBe(1);
380
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
381
- });
382
-
383
- // DEV-1768 fix #3: a scope shrink must QUARANTINE clean orphans (recoverable),
384
- // never silently delete them. The file leaves its working-tree path but is
385
- // recoverable under `.hq/scope-quarantine/<slug>/`.
386
- it("scope shrink QUARANTINES a clean orphan (recoverable), never silent-deletes", async () => {
387
- const quarantined = path.join(
388
- tmpDir,
389
- ".hq",
390
- "scope-quarantine",
391
- "acme",
392
- "docs",
393
- "handoff.md",
394
- );
395
- await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
396
- const original = companyRel("docs/handoff.md");
397
- const bodyBefore = fs.readFileSync(original, "utf-8");
398
-
399
- const shared = await sync({
400
- company: "acme",
401
- vaultConfig: mockConfig,
402
- hqRoot: tmpDir,
403
- syncMode: "shared",
404
- prefixSet: ["knowledge/"],
405
- });
406
-
407
- expect(shared.scopeOrphansRemoved).toBe(1);
408
- // Gone from the working tree...
409
- expect(fs.existsSync(original)).toBe(false);
410
- // ...but recoverable in quarantine, byte-for-byte.
411
- expect(fs.existsSync(quarantined)).toBe(true);
412
- expect(fs.readFileSync(quarantined, "utf-8")).toBe(bodyBefore);
413
- });
414
-
415
- // DEV-1768 fix #1 (recovery) + #2: the background runner path
416
- // (`scopeShrinkPolicy: "auto-recover"`) must NEVER throw on a scope shrink —
417
- // it self-heals. This reproduces the EXACT wedge: a buggy `all`-mode pull
418
- // seeds the journal (all-mode PullRecord + both files), one out-of-scope file
419
- // is edited locally (dirty), then the real `shared` pull arrives. Under the
420
- // old code this threw ScopeShrinkBlockedError(all→shared) → exit 2 forever.
421
- it("auto-recover policy clears an all→shared wedge: dirty kept on disk, clean quarantined, no throw", async () => {
422
- // 1. Buggy all-mode CLI seed: journals everything + stamps an all-mode record.
423
- await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
424
- // 2. User edits an out-of-scope file → it becomes a DIRTY orphan.
425
- fs.writeFileSync(companyRel("docs/handoff.md"), "LOCAL EDIT — keep me");
426
- const quarantinedClean = path.join(
427
- tmpDir, ".hq", "scope-quarantine", "acme", "knowledge", "readme.md",
428
- );
429
-
430
- // 3. The menubar runner's real shared pull. prefixSet covers NEITHER file
431
- // (docs is dirty-orphan, knowledge becomes a clean orphan) so we exercise
432
- // both dispositions. Must resolve without throwing.
433
- const recovered = await sync({
434
- company: "acme",
435
- vaultConfig: mockConfig,
436
- hqRoot: tmpDir,
437
- syncMode: "shared",
438
- prefixSet: ["projects/"],
439
- scopeShrinkPolicy: "auto-recover",
440
- });
441
- expect(recovered.aborted).toBe(false);
442
- // Dirty file: KEPT on disk (un-tracked), counted as blocked-but-kept.
443
- expect(recovered.scopeOrphansBlocked).toBe(1);
444
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
445
- expect(fs.readFileSync(companyRel("docs/handoff.md"), "utf-8")).toBe(
446
- "LOCAL EDIT — keep me",
447
- );
448
- // Clean file: quarantined (recoverable), removed from the working tree.
449
- expect(recovered.scopeOrphansRemoved).toBe(1);
450
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
451
- expect(fs.existsSync(quarantinedClean)).toBe(true);
452
-
453
- // 4. Idempotent: the next auto-recover sync re-flags NOTHING (the orphans
454
- // were tombstoned, so the wedge does not recur).
455
- const second = await sync({
456
- company: "acme",
457
- vaultConfig: mockConfig,
458
- hqRoot: tmpDir,
459
- syncMode: "shared",
460
- prefixSet: ["projects/"],
461
- scopeShrinkPolicy: "auto-recover",
462
- });
463
- expect(second.scopeOrphansRemoved).toBe(0);
464
- expect(second.scopeOrphansBlocked).toBe(0);
465
- });
466
-
467
- // ── US-006: sessions prefix push-only pull-scope exclusion ─────────────────
468
- // The sessions prefix is PUSH-ONLY: pushed into the vault but never
469
- // auto-pulled onto grantee laptops in ANY mode (including `all`, which
470
- // otherwise pulls the whole bucket). The engine expresses this as
471
- // `excludePrefixes`, applied to the DOWNLOAD filter only — never to
472
- // scope-shrink, so an authored/pinned session is never orphaned.
473
- describe("sessions push-only exclusion (US-006)", () => {
474
- const withSession = () => {
475
- REMOTE.current = [
476
- { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
477
- { key: "sessions/prs_1/2026-07-11T10-00-00.json", size: 50, lastModified: new Date(), etag: '"sess1"' },
478
- ];
479
- };
480
- const sessionPath = "sessions/prs_1/2026-07-11T10-00-00.json";
481
-
482
- it("AC4: `all` mode subtracts excludePrefixes — a sessions key is skip-out-of-scope; non-session downloads", async () => {
483
- withSession();
484
- const result = await sync({
485
- company: "acme",
486
- vaultConfig: mockConfig,
487
- hqRoot: tmpDir,
488
- syncMode: "all",
489
- excludePrefixes: ["sessions/"],
490
- });
491
- // `all` normally pulls everything; the exclusion carves sessions out.
492
- expect(result.filesDownloaded).toBe(1);
493
- expect(result.filesOutOfScope).toBe(1);
494
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
495
- expect(fs.existsSync(companyRel(sessionPath))).toBe(false);
496
- });
497
-
498
- it("AC4: excludePrefixes is NOT fed to scope-shrink — a previously-pulled session file is never orphaned", async () => {
499
- withSession();
500
- // 1. Full pull WITHOUT the exclusion: the session materializes and journals
501
- // as a pulled (direction:"down") entry under an `all`-mode PullRecord.
502
- const first = await sync({
503
- company: "acme",
504
- vaultConfig: mockConfig,
505
- hqRoot: tmpDir,
506
- syncMode: "all",
507
- });
508
- expect(first.filesDownloaded).toBe(2);
509
- expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
510
-
511
- // 2. Full pull WITH the sessions exclusion. The session is now
512
- // skip-out-of-scope (not re-pulled) but MUST NOT be pruned: the exclude
513
- // set never reaches scope-shrink, whose currentPrefixSet stays `[""]`
514
- // (covers everything). If it WERE fed in, the file would be orphaned.
515
- const second = await sync({
516
- company: "acme",
517
- vaultConfig: mockConfig,
518
- hqRoot: tmpDir,
519
- syncMode: "all",
520
- excludePrefixes: ["sessions/"],
521
- });
522
- expect(second.filesOutOfScope).toBe(1);
523
- expect(second.scopeOrphansRemoved).toBe(0);
524
- expect(second.scopeOrphansBlocked).toBe(0);
525
- expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
526
- });
527
-
528
- it("AC6: a pinned/in-scope session survives scope-shrink even though excludePrefixes skips its re-pull", async () => {
529
- withSession();
530
- // Materialize both under all-mode (knowledge sync + an on-demand session get).
531
- await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
532
- expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
533
-
534
- // A scoped shared pull whose inclusion prefixSet includes the pinned
535
- // session key (as resolvePullScope would after `hq files get` pins it),
536
- // plus the push-only sessions exclusion. The session is NOT re-pulled
537
- // (exclusion), but the pin keeps it in scope-shrink's currentPrefixSet, so
538
- // it is retained rather than orphaned.
539
- const scoped = await sync({
540
- company: "acme",
541
- vaultConfig: mockConfig,
542
- hqRoot: tmpDir,
543
- syncMode: "shared",
544
- prefixSet: ["knowledge/", sessionPath],
545
- excludePrefixes: ["sessions/"],
546
- });
547
- expect(scoped.scopeOrphansRemoved).toBe(0);
548
- expect(scoped.scopeOrphansBlocked).toBe(0);
549
- // Session is not re-downloaded (excluded) but survives on disk...
550
- expect(scoped.filesOutOfScope).toBe(1);
551
- expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
552
- // ...and the in-scope knowledge file is retained too.
553
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
554
- });
555
-
556
- // ── feedback_d2082110 follow-up ────────────────────────────────────────
557
- // `skip-out-of-scope` carries TWO causes with OPPOSITE remedies:
558
- // membership scope (fixed by raising the access level to `all`) and the
559
- // push-only sessions exclusion (NOT fixed by that — sessions are withheld
560
- // in every mode, `all` included, and materialize only via `hq files get`).
561
- // The `scope-materialization-gap` advisory names the shared→all lever, so
562
- // it must count and sample ONLY membership-withheld keys. Counting session
563
- // keys inflates the gap, can fill the sample list with keys the lever will
564
- // never deliver, and can fire the whole advisory when the membership scope
565
- // withheld nothing — sending the reporter back into exactly the
566
- // flip-everything-to-`all`-and-still-miss-files loop the event exists to end.
567
- describe("materialization gap excludes push-only keys (feedback_d2082110)", () => {
568
- const collectEvents = () => {
569
- const events: Array<Record<string, unknown>> = [];
570
- return {
571
- events,
572
- onEvent: (e: unknown) => events.push(e as Record<string, unknown>),
573
- };
574
- };
575
- const gapOf = (events: Array<Record<string, unknown>>) =>
576
- events.find((e) => e.type === "scope-materialization-gap");
577
-
578
- it("a leg withholding ONLY push-only session keys emits no gap — the shared→all lever would not materialize them", async () => {
579
- withSession();
580
- const { events, onEvent } = collectEvents();
581
- const result = await sync({
582
- company: "acme",
583
- vaultConfig: mockConfig,
584
- hqRoot: tmpDir,
585
- syncMode: "shared",
586
- prefixSet: ["knowledge/"],
587
- excludePrefixes: ["sessions/"],
588
- onEvent,
589
- });
590
-
591
- // The session key is still withheld and still counted on the
592
- // both-causes `filesOutOfScope` axis — that contract is unchanged.
593
- expect(result.filesDownloaded).toBe(1);
594
- expect(result.filesOutOfScope).toBe(1);
595
- expect(fs.existsSync(companyRel(sessionPath))).toBe(false);
596
- // But the membership scope withheld nothing, so there is no
597
- // shared-vs-all gap to report and no `all` lever to advertise.
598
- expect(gapOf(events)).toBeUndefined();
599
- });
600
-
601
- it("counts and samples only membership-withheld keys when both causes fire on one leg", async () => {
602
- REMOTE.current = [
603
- { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
604
- { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
605
- { key: sessionPath, size: 50, lastModified: new Date(), etag: '"sess1"' },
606
- ];
607
- const { events, onEvent } = collectEvents();
608
- const result = await sync({
609
- company: "acme",
610
- vaultConfig: mockConfig,
611
- hqRoot: tmpDir,
612
- syncMode: "shared",
613
- prefixSet: ["knowledge/"],
614
- excludePrefixes: ["sessions/"],
615
- onEvent,
616
- });
617
-
618
- // Two keys withheld overall: one by the membership scope, one push-only.
619
- expect(result.filesDownloaded).toBe(1);
620
- expect(result.filesOutOfScope).toBe(2);
621
-
622
- // The advisory reports ONLY the one the `all` lever would deliver.
623
- const gap = gapOf(events);
624
- expect(gap).toBeDefined();
625
- expect(gap!.count).toBe(1);
626
- expect(gap!.syncMode).toBe("shared");
627
- expect(gap!.samplePaths).toEqual(["docs/handoff.md"]);
628
- });
629
-
630
- it("a pinned session key withheld on the push-only axis alone is not a membership gap", async () => {
631
- // Exercises the OTHER classification branch: the key PASSES the
632
- // inclusion check (it is pinned into prefixSet, as resolvePullScope
633
- // does after `hq files get`) and is withheld by the exclusion filter.
634
- withSession();
635
- const { events, onEvent } = collectEvents();
636
- const result = await sync({
637
- company: "acme",
638
- vaultConfig: mockConfig,
639
- hqRoot: tmpDir,
640
- syncMode: "shared",
641
- prefixSet: ["knowledge/", sessionPath],
642
- excludePrefixes: ["sessions/"],
643
- onEvent,
644
- });
645
-
646
- expect(result.filesDownloaded).toBe(1);
647
- expect(result.filesOutOfScope).toBe(1);
648
- expect(gapOf(events)).toBeUndefined();
649
- });
650
- });
651
- });
652
- });