@indigoai-us/hq-cloud 5.40.0 → 5.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/bin/sync-runner.d.ts +26 -1
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +105 -0
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +168 -1
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/sync-scope.test.d.ts +22 -0
  8. package/dist/cli/sync-scope.test.d.ts.map +1 -0
  9. package/dist/cli/sync-scope.test.js +273 -0
  10. package/dist/cli/sync-scope.test.js.map +1 -0
  11. package/dist/cli/sync.d.ts +64 -0
  12. package/dist/cli/sync.d.ts.map +1 -1
  13. package/dist/cli/sync.js +152 -4
  14. package/dist/cli/sync.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/prefix-coalesce.d.ts +29 -0
  20. package/dist/prefix-coalesce.d.ts.map +1 -1
  21. package/dist/prefix-coalesce.js +48 -0
  22. package/dist/prefix-coalesce.js.map +1 -1
  23. package/dist/prefix-coalesce.test.js +51 -1
  24. package/dist/prefix-coalesce.test.js.map +1 -1
  25. package/dist/qmd-reindex.d.ts +59 -0
  26. package/dist/qmd-reindex.d.ts.map +1 -0
  27. package/dist/qmd-reindex.js +128 -0
  28. package/dist/qmd-reindex.js.map +1 -0
  29. package/dist/qmd-reindex.test.d.ts +10 -0
  30. package/dist/qmd-reindex.test.d.ts.map +1 -0
  31. package/dist/qmd-reindex.test.js +129 -0
  32. package/dist/qmd-reindex.test.js.map +1 -0
  33. package/dist/scope-shrink.d.ts +18 -0
  34. package/dist/scope-shrink.d.ts.map +1 -1
  35. package/dist/scope-shrink.js +28 -0
  36. package/dist/scope-shrink.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/bin/sync-runner.test.ts +222 -0
  39. package/src/bin/sync-runner.ts +124 -0
  40. package/src/cli/sync-scope.test.ts +307 -0
  41. package/src/cli/sync.ts +240 -1
  42. package/src/index.ts +1 -0
  43. package/src/prefix-coalesce.test.ts +76 -1
  44. package/src/prefix-coalesce.ts +45 -0
  45. package/src/qmd-reindex.test.ts +143 -0
  46. package/src/qmd-reindex.ts +151 -0
  47. package/src/scope-shrink.ts +28 -0
package/src/cli/sync.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import * as fs from "fs";
9
9
  import * as path from "path";
10
10
  import type { VaultServiceConfig, SyncJournal } from "../types.js";
11
+ import type { SyncMode } from "../vault-client.js";
11
12
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
13
  import { downloadFile, listRemoteFiles, headRemoteFile } from "../s3.js";
13
14
  import type { RemoteFile } from "../s3.js";
@@ -20,7 +21,19 @@ import {
20
21
  removeEntry,
21
22
  getEntry,
22
23
  normalizeEtag,
24
+ migrateToV2,
25
+ gcTombstones,
26
+ lastPullRecord,
27
+ appendPullRecord,
28
+ generatePullId,
23
29
  } from "../journal.js";
30
+ import {
31
+ buildScopeShrinkPlan,
32
+ applyScopeShrink,
33
+ ScopeShrinkBlockedError,
34
+ ScopeShrinkLargePruneError,
35
+ } from "../scope-shrink.js";
36
+ import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
24
37
  import { createIgnoreFilter } from "../ignore.js";
25
38
  import { isEphemeralPath } from "./share.js";
26
39
  import { resolveConflict } from "./conflict.js";
@@ -255,6 +268,40 @@ export interface SyncOptions {
255
268
  * TS runner and Rust first-push share idempotency state.
256
269
  */
257
270
  journalSlug?: string;
271
+ /**
272
+ * Effective sync mode for this leg (US-005 wiring). Defaults to `"all"`
273
+ * when absent, preserving the legacy full-bucket pull. The runner resolves
274
+ * this from the membership's sync-config (`getMembershipSyncConfig`).
275
+ *
276
+ * SECURITY NOTE: this is a footprint/UX filter, NOT an authorization
277
+ * boundary. The security boundary is the server (STS credential scope +
278
+ * ACL). An owner's STS is wide (role-bypass), so this client-side scope is
279
+ * what makes selective download durable for owners — but it never grants
280
+ * access beyond what STS already permits.
281
+ */
282
+ syncMode?: SyncMode;
283
+ /**
284
+ * Coalesced, COMPANY-RELATIVE prefixes the current pull is scoped to when
285
+ * `syncMode` is `"shared"` or `"custom"` (same namespace as `RemoteFile.key`
286
+ * and the per-slug journal keys — e.g. `"knowledge/"`, `"projects/x/"`).
287
+ * Ignored when `syncMode` is `"all"`. The runner derives this from the
288
+ * caller's explicit grants (`shared`) or `customPaths` (`custom`) and is
289
+ * responsible for normalizing into the company-relative namespace.
290
+ *
291
+ * A `shared` leg with an empty/undefined `prefixSet` means "nothing is
292
+ * shared with me" → download nothing. The runner MUST fall back to `"all"`
293
+ * (not empty `"shared"`) on any grant-resolution error, so a transient
294
+ * failure can never silently prune the local tree.
295
+ */
296
+ prefixSet?: string[];
297
+ /**
298
+ * When the effective scope shrinks relative to the last pull and the shrink
299
+ * would orphan locally-modified ("dirty") files, `sync()` aborts with a
300
+ * `ScopeShrinkBlockedError` by default. Set `true` to proceed anyway:
301
+ * dirty files are LEFT ON DISK and only their journal entries are
302
+ * tombstoned. Mirrors `hq sync narrow --force`.
303
+ */
304
+ forceScopeShrink?: boolean;
258
305
  }
259
306
 
260
307
  export interface SyncResult {
@@ -297,6 +344,42 @@ export interface SyncResult {
297
344
  * disappeared from the remote.
298
345
  */
299
346
  filesTombstoned: number;
347
+ /**
348
+ * Count of remote keys NOT downloaded this run because they fall outside
349
+ * the effective `syncMode` scope (US-005). Always 0 in `all` mode. Distinct
350
+ * from `filesSkipped` (which measures "unchanged on this run") so consumers
351
+ * can render a "N outside your sync scope" line. The matching local cleanup
352
+ * of previously-downloaded-now-out-of-scope files is reported via
353
+ * `scopeOrphansRemoved`.
354
+ */
355
+ filesOutOfScope: number;
356
+ /**
357
+ * Clean local orphans deleted this run because a scope shrink moved them
358
+ * outside the effective scope (US-005). 0 when scope did not shrink.
359
+ */
360
+ scopeOrphansRemoved: number;
361
+ /**
362
+ * Dirty (locally-modified) orphans that a scope shrink would have pruned.
363
+ * When `forceScopeShrink` is false these are surfaced via a thrown
364
+ * `ScopeShrinkBlockedError` and the leg never reaches this result; when
365
+ * true they are left on disk and tombstoned, and counted here.
366
+ */
367
+ scopeOrphansBlocked: number;
368
+ }
369
+
370
+ /**
371
+ * Resolve the auto-prune safety cap (US-005 bulk-delete guard). An automatic
372
+ * scope shrink that would delete more than this many CLEAN local files in one
373
+ * pull is refused with `ScopeShrinkLargePruneError`. Default 100; `0` (or a
374
+ * non-positive / unparseable value) disables the cap (unlimited). Override via
375
+ * `HQ_SYNC_MAX_AUTO_PRUNE`.
376
+ */
377
+ export function resolveAutoPruneCap(): number {
378
+ const raw = process.env.HQ_SYNC_MAX_AUTO_PRUNE;
379
+ if (raw === undefined || raw === "") return 100;
380
+ const parsed = Number.parseInt(raw, 10);
381
+ // NaN or negative → treat as "unlimited" (0) rather than silently capping.
382
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
300
383
  }
301
384
 
302
385
  /**
@@ -330,13 +413,32 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
330
413
  : path.join(hqRoot, "companies", ctx.slug);
331
414
  const shouldSync = createIgnoreFilter(hqRoot);
332
415
  const journalSlug = options.journalSlug ?? ctx.slug;
333
- const journal = readJournal(journalSlug);
416
+ const startedAt = new Date().toISOString();
417
+ // Migrate v1 → v2 in place so the scope-shrink / pull-record machinery has
418
+ // its fields, and GC any tombstones past the 30-day retention window before
419
+ // we re-evaluate orphans (so a long-pruned path can re-download cleanly).
420
+ const journal = migrateToV2(readJournal(journalSlug));
421
+ gcTombstones(journal, Date.now());
422
+
423
+ // ── Effective download scope (US-005) ─────────────────────────────────────
424
+ // `all` → prefixSet `[""]`, which `isCoveredByAny` treats as "covers
425
+ // everything" — so the download filter and the scope-shrink
426
+ // comparison both become no-ops, preserving legacy full-bucket
427
+ // behavior bit-for-bit.
428
+ // `shared`/`custom` → the coalesced, company-relative prefix set the runner
429
+ // resolved. An empty set means "nothing in scope" → download
430
+ // nothing (the runner falls back to `all` on resolution errors, so
431
+ // empty here is an intentional "nothing shared", never a failure).
432
+ const syncMode: SyncMode = options.syncMode ?? "all";
433
+ const currentPrefixSet =
434
+ syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
334
435
 
335
436
  let filesDownloaded = 0;
336
437
  let bytesDownloaded = 0;
337
438
  let filesSkipped = 0;
338
439
  let conflicts = 0;
339
440
  let filesTombstoned = 0;
441
+ let filesOutOfScope = 0;
340
442
  const conflictPaths: string[] = [];
341
443
 
342
444
  // List all remote files (IAM session policy filters at the AWS layer)
@@ -353,6 +455,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
353
455
  options.personalMode === true,
354
456
  options.includeLocalCompanies === true,
355
457
  options.teamSyncedSlugs ?? null,
458
+ currentPrefixSet,
356
459
  );
357
460
 
358
461
  emit({
@@ -367,6 +470,83 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
367
470
  filesToDelete: 0,
368
471
  });
369
472
 
473
+ // ── Scope-shrink cleanup (US-005) ─────────────────────────────────────────
474
+ // If the effective scope narrowed since the last pull, files that were
475
+ // pulled under the old scope but fall outside the new one are orphans. We
476
+ // delete only CLEAN orphans (provably unchanged since last sync); dirty
477
+ // (locally-modified) orphans are sacred. By default a dirty orphan aborts
478
+ // the leg with a structured error the CLI renders; `forceScopeShrink` keeps
479
+ // dirty files on disk and only tombstones their journal entries.
480
+ //
481
+ // `companyRoot` is passed as the module's `hqRoot` so its `path.join(root,
482
+ // key)` resolves company-relative journal keys correctly (the scope-shrink
483
+ // module is namespace-agnostic — root + keys + prefixSet must simply agree).
484
+ //
485
+ // Note: this is the durable selective-download fix for OWNERS. An owner's
486
+ // STS is wide (role-bypass), so the remote LIST returns everything and the
487
+ // AWS layer never narrows the pull. This client-side shrink is what makes
488
+ // `hq sync mode shared` actually stick across re-syncs for an owner.
489
+ const lastRecord = lastPullRecord(journal, ctx.uid);
490
+ // A missing record, or a v1-migrated record with an empty prefixSet, means
491
+ // "no recorded scope" → treat the last scope as full-bucket `all` (`[""]`),
492
+ // per the PullRecord.prefixSet contract in types.ts.
493
+ const lastPrefixSet =
494
+ lastRecord && lastRecord.prefixSet.length > 0
495
+ ? lastRecord.prefixSet
496
+ : [""];
497
+ const shrinkPlan = buildScopeShrinkPlan({
498
+ journal,
499
+ hqRoot: companyRoot,
500
+ lastPrefixSet,
501
+ currentPrefixSet,
502
+ });
503
+ if (shrinkPlan.dirty.length > 0 && options.forceScopeShrink !== true) {
504
+ throw new ScopeShrinkBlockedError(
505
+ ctx.uid,
506
+ lastRecord?.syncMode ?? "unknown",
507
+ syncMode,
508
+ shrinkPlan.dirty,
509
+ shrinkPlan.clean,
510
+ );
511
+ }
512
+ // Bulk-delete guard: refuse to auto-prune more than the safety cap of CLEAN
513
+ // files in a single background sync. A deliberate large narrow goes through
514
+ // `hq sync narrow --apply` (its own confirmation), and `--force-scope-shrink`
515
+ // (or raising HQ_SYNC_MAX_AUTO_PRUNE) overrides. Cap of 0 = unlimited (opt
516
+ // out). The engine deletes nothing when it throws here.
517
+ const autoPruneCap = resolveAutoPruneCap();
518
+ if (
519
+ options.forceScopeShrink !== true &&
520
+ autoPruneCap > 0 &&
521
+ shrinkPlan.clean.length > autoPruneCap
522
+ ) {
523
+ throw new ScopeShrinkLargePruneError(
524
+ ctx.uid,
525
+ syncMode,
526
+ shrinkPlan.clean.length,
527
+ autoPruneCap,
528
+ );
529
+ }
530
+ const shrinkResult = applyScopeShrink({
531
+ journal,
532
+ plan: shrinkPlan,
533
+ hqRoot: companyRoot,
534
+ forceScopeShrink: options.forceScopeShrink === true,
535
+ reason: "scope_shrink",
536
+ });
537
+ // Surface each removed clean orphan as a `deleted` progress event so the
538
+ // menubar stream renders the prune the same way it renders a cross-machine
539
+ // tombstone (the Rust parser already handles `deleted: true`).
540
+ for (const orphan of shrinkPlan.clean) {
541
+ emit({
542
+ type: "progress",
543
+ path: orphan.path,
544
+ bytes: 0,
545
+ deleted: true,
546
+ message: "scope-narrowed (removed local copy outside sync scope)",
547
+ });
548
+ }
549
+
370
550
  // Stage 2: execute the plan. Per-item branching mirrors the pre-refactor
371
551
  // inline loop; the only structural change is that classification has
372
552
  // already happened (so `localHash` is reused instead of re-hashing).
@@ -419,6 +599,13 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
419
599
  // run", not a catch-all for everything we didn't download.
420
600
  continue;
421
601
  }
602
+ if (item.action === "skip-out-of-scope") {
603
+ // Outside the effective `syncMode` scope (US-005). Counted on its own
604
+ // axis so `filesSkipped` keeps meaning "unchanged on this run" — these
605
+ // are "deliberately not downloaded because of your sync scope".
606
+ filesOutOfScope++;
607
+ continue;
608
+ }
422
609
 
423
610
  if (item.action === "download") {
424
611
  downloadItems.push(item);
@@ -515,6 +702,12 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
515
702
  // 0 so the field shape stays stable for consumers that
516
703
  // destructure it.
517
704
  filesTombstoned: 0,
705
+ // Scope-shrink ran before execution, so its counts are real even on
706
+ // a conflict abort. `filesOutOfScope` reflects how far the serial
707
+ // pass got before the abort; that's acceptable for an abort result.
708
+ filesOutOfScope,
709
+ scopeOrphansRemoved: shrinkResult.cleanRemoved,
710
+ scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
518
711
  };
519
712
  break;
520
713
  }
@@ -841,6 +1034,22 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
841
1034
  });
842
1035
  }
843
1036
 
1037
+ // Record this pull's boundary (US-005) so the NEXT pull can diff its scope
1038
+ // against ours and detect a shrink. Append before the journal write so it
1039
+ // persists. `prefixSet` is stored in the same company-relative namespace as
1040
+ // the journal keys; `all` mode records `[""]` (covers everything).
1041
+ appendPullRecord(journal, {
1042
+ pullId: generatePullId(),
1043
+ companyUid: ctx.uid,
1044
+ startedAt,
1045
+ completedAt: new Date().toISOString(),
1046
+ syncMode,
1047
+ prefixSet: currentPrefixSet,
1048
+ scopeChangeDetected: shrinkPlan.scopeChangeDetected,
1049
+ orphansRemoved: shrinkResult.cleanRemoved,
1050
+ orphansBlocked: shrinkResult.dirtyTombstoned,
1051
+ });
1052
+
844
1053
  // Stamp lastSync on every successful run so the menubar's "Last sync · X ago"
845
1054
  // ticks even when nothing transferred. updateEntry only fires on actual
846
1055
  // downloads; without this, a no-op sync leaves lastSync at the time of the
@@ -859,6 +1068,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
859
1068
  newFilesCount: plan.newFilesCount,
860
1069
  filesExcludedByPolicy: plan.filesExcludedByPolicy,
861
1070
  filesTombstoned,
1071
+ filesOutOfScope,
1072
+ scopeOrphansRemoved: shrinkResult.cleanRemoved,
1073
+ scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
862
1074
  };
863
1075
  }
864
1076
 
@@ -911,6 +1123,10 @@ type PullPlanItem =
911
1123
  // refused to upload these since 5.33.0; the pull walker now refuses to
912
1124
  // download them so legacy litter in cloud staging drains naturally.
913
1125
  | { action: "skip-excluded-policy"; remoteFile: RemoteFile; localPath: string }
1126
+ // Remote keys outside the effective `syncMode` scope (US-005). Present in
1127
+ // the remote LIST (and accessible per STS) but deliberately not downloaded
1128
+ // because the membership's sync scope doesn't cover them.
1129
+ | { action: "skip-out-of-scope"; remoteFile: RemoteFile; localPath: string }
914
1130
  | {
915
1131
  action: "conflict";
916
1132
  remoteFile: RemoteFile;
@@ -945,6 +1161,8 @@ interface PullPlan {
945
1161
  newFilesCount: number;
946
1162
  /** Count of remote keys refused by ephemeral-mirror policy. */
947
1163
  filesExcludedByPolicy: number;
1164
+ /** Count of remote keys skipped because they fall outside the sync scope. */
1165
+ filesOutOfScope: number;
948
1166
  /**
949
1167
  * Journal-known keys missing from the remote LIST. The executor will
950
1168
  * apply each as a local delete (file or symlink) + journal removal,
@@ -973,6 +1191,10 @@ function computePullPlan(
973
1191
  personalMode: boolean,
974
1192
  includeLocalCompanies: boolean,
975
1193
  teamSyncedSlugs: ReadonlySet<string> | null,
1194
+ // Coalesced, company-relative prefixes the pull is scoped to (US-005).
1195
+ // `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
1196
+ // the scope filter below becomes a no-op and legacy behavior is preserved.
1197
+ prefixSet: string[],
976
1198
  ): PullPlan {
977
1199
  const items: PullPlanItem[] = [];
978
1200
 
@@ -1017,6 +1239,17 @@ function computePullPlan(
1017
1239
  }
1018
1240
  }
1019
1241
 
1242
+ // Scope filter (US-005). Keys outside the effective `syncMode` prefix set
1243
+ // are not downloaded. `prefixSet` is `[""]` in `all` mode, which
1244
+ // `isCoveredByAny` treats as covering everything — so this is a no-op for
1245
+ // `all` and preserves the legacy full-bucket pull bit-for-bit. The
1246
+ // previously-downloaded counterparts of these keys (if scope just shrank)
1247
+ // are pruned separately by the scope-shrink pass in `sync()`.
1248
+ if (!isCoveredByAny(remoteFile.key, prefixSet)) {
1249
+ items.push({ action: "skip-out-of-scope", remoteFile, localPath });
1250
+ continue;
1251
+ }
1252
+
1020
1253
  // LIST gives us no kind signal for the remote object — we don't
1021
1254
  // know whether this key is a regular file or a symlink record
1022
1255
  // until we either HEAD it (expensive — N extra calls per pull) or
@@ -1175,6 +1408,7 @@ function computePullPlan(
1175
1408
  let filesToSkip = 0;
1176
1409
  let filesToConflict = 0;
1177
1410
  let filesExcludedByPolicy = 0;
1411
+ let filesOutOfScope = 0;
1178
1412
  const newFiles: Array<{ path: string; bytes: number }> = [];
1179
1413
  for (const item of items) {
1180
1414
  if (item.action === "download") {
@@ -1191,6 +1425,10 @@ function computePullPlan(
1191
1425
  // distinct class surfaced via filesExcludedByPolicy so consumers
1192
1426
  // can render a "N refused by policy" line independently of the
1193
1427
  // generic "N unchanged" tally.
1428
+ } else if (item.action === "skip-out-of-scope") {
1429
+ // Out-of-scope items get their own axis too, mirroring excluded-policy:
1430
+ // they're "deliberately not downloaded (sync scope)", not "unchanged".
1431
+ filesOutOfScope++;
1194
1432
  } else {
1195
1433
  filesToSkip++;
1196
1434
  }
@@ -1294,6 +1532,7 @@ function computePullPlan(
1294
1532
  newFiles,
1295
1533
  newFilesCount: newFiles.length,
1296
1534
  filesExcludedByPolicy,
1535
+ filesOutOfScope,
1297
1536
  tombstones,
1298
1537
  };
1299
1538
  }
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  export {
47
47
  coalescePrefixes,
48
48
  isCoveredByAny,
49
+ grantPathToPrefix,
49
50
  } from "./prefix-coalesce.js";
50
51
 
51
52
  // Scope-shrink detection + application (US-005)
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { coalescePrefixes, isCoveredByAny } from "./prefix-coalesce.js";
2
+ import {
3
+ coalescePrefixes,
4
+ isCoveredByAny,
5
+ grantPathToPrefix,
6
+ } from "./prefix-coalesce.js";
3
7
 
4
8
  describe("coalescePrefixes", () => {
5
9
  it("returns empty for empty input", () => {
@@ -93,3 +97,74 @@ describe("isCoveredByAny", () => {
93
97
  expect(isCoveredByAny("A/b/c.md", ["a/"])).toBe(false);
94
98
  });
95
99
  });
100
+
101
+ describe("grantPathToPrefix", () => {
102
+ const slug = "indigo";
103
+
104
+ it("strips a companies/<slug>/ anchor", () => {
105
+ expect(grantPathToPrefix("companies/indigo/knowledge/README.md", slug)).toBe(
106
+ "knowledge/README.md",
107
+ );
108
+ });
109
+
110
+ it("strips a bare <slug>/ anchor", () => {
111
+ expect(grantPathToPrefix("indigo/data/vyg/old-meetings/*", slug)).toBe(
112
+ "data/vyg/old-meetings/",
113
+ );
114
+ });
115
+
116
+ it("folds a trailing /* glob into a directory prefix", () => {
117
+ expect(grantPathToPrefix("data/vyg/*", slug)).toBe("data/vyg/");
118
+ expect(grantPathToPrefix("companies/indigo/design-pack/*", slug)).toBe(
119
+ "design-pack/",
120
+ );
121
+ });
122
+
123
+ it("folds a trailing bare * into its parent prefix", () => {
124
+ expect(grantPathToPrefix("reports*", slug)).toBe("reports");
125
+ });
126
+
127
+ it("leaves a company-relative directory prefix unchanged", () => {
128
+ expect(grantPathToPrefix("knowledge/security/", slug)).toBe(
129
+ "knowledge/security/",
130
+ );
131
+ });
132
+
133
+ it("leaves a company-relative exact key unchanged", () => {
134
+ expect(grantPathToPrefix("company.yaml", slug)).toBe("company.yaml");
135
+ });
136
+
137
+ it("maps a bare '*' (and empty) to '' = everything", () => {
138
+ expect(grantPathToPrefix("*", slug)).toBe("");
139
+ expect(grantPathToPrefix("", slug)).toBe("");
140
+ });
141
+
142
+ it("tolerates leading slashes", () => {
143
+ expect(grantPathToPrefix("/companies/indigo/x/*", slug)).toBe("x/");
144
+ });
145
+
146
+ it("does not strip a different company's anchor (defensive)", () => {
147
+ // A grant anchored at another slug is left intact (won't match this
148
+ // company's keys, which is the safe outcome).
149
+ expect(grantPathToPrefix("companies/other/x/*", slug)).toBe(
150
+ "companies/other/x/",
151
+ );
152
+ });
153
+
154
+ it("round-trips through coalescePrefixes + isCoveredByAny against real keys", () => {
155
+ const grants = [
156
+ "companies/indigo/design-pack/*",
157
+ "companies/indigo/knowledge/README.md",
158
+ "data/vyg/*",
159
+ "company.yaml",
160
+ ];
161
+ const prefixes = coalescePrefixes(grants.map((g) => grantPathToPrefix(g, slug)));
162
+ expect(isCoveredByAny("design-pack/logo.svg", prefixes)).toBe(true);
163
+ expect(isCoveredByAny("knowledge/README.md", prefixes)).toBe(true);
164
+ expect(isCoveredByAny("data/vyg/2026/q1.md", prefixes)).toBe(true);
165
+ expect(isCoveredByAny("company.yaml", prefixes)).toBe(true);
166
+ // Out of scope:
167
+ expect(isCoveredByAny("secrets/db.json", prefixes)).toBe(false);
168
+ expect(isCoveredByAny("knowledge/OTHER.md", prefixes)).toBe(false);
169
+ });
170
+ });
@@ -70,3 +70,48 @@ export function isCoveredByAny(
70
70
  }
71
71
  return false;
72
72
  }
73
+
74
+ /**
75
+ * Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
76
+ * for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`
77
+ * matching against company-relative `RemoteFile.key`s).
78
+ *
79
+ * Real-world grant paths are inconsistent — observed forms in production:
80
+ * - `*` → everything (company-wide glob)
81
+ * - `companies/<slug>/design-pack/*` → full-anchored + trailing glob
82
+ * - `companies/<slug>/knowledge/README.md` → full-anchored exact file
83
+ * - `<slug>/data/vyg/old-meetings/*` → slug-anchored + trailing glob
84
+ * - `data/vyg/*` → company-relative + trailing glob
85
+ * - `company.yaml` → company-relative exact file
86
+ *
87
+ * The engine's keys are ALWAYS company-relative (`design-pack/x`,
88
+ * `company.yaml`). So we:
89
+ * 1. strip a leading `companies/<slug>/` or `<slug>/` anchor, and
90
+ * 2. fold the ACL glob suffix into a `startsWith`-friendly prefix:
91
+ * - `*` (bare) → `""` (covers everything)
92
+ * - `foo/bar/*` or `foo/bar*` → `foo/bar/` / `foo/bar`
93
+ * - `foo/bar/` (dir) → unchanged
94
+ * - `foo/bar.md` (exact) → unchanged (an exact key is its own prefix)
95
+ *
96
+ * Without this, `coalescePrefixes(grants.map(g => g.path))` produced prefixes
97
+ * with trailing `*` (which never `startsWith`-match a real key) and
98
+ * full/slug-anchored prefixes (which never match company-relative keys) — so
99
+ * `syncMode: shared` would download nothing and prune everything the caller
100
+ * actually has access to. See the live grant dump in the hq-pro vault.
101
+ */
102
+ export function grantPathToPrefix(grantPath: string, slug: string): string {
103
+ let p = (grantPath ?? "").replace(/^\/+/, "");
104
+ // 1. Strip the company anchor, if present.
105
+ const companyAnchor = `companies/${slug}/`;
106
+ const slugAnchor = `${slug}/`;
107
+ if (p.startsWith(companyAnchor)) {
108
+ p = p.slice(companyAnchor.length);
109
+ } else if (p.startsWith(slugAnchor)) {
110
+ p = p.slice(slugAnchor.length);
111
+ }
112
+ // 2. Fold the ACL glob suffix into a startsWith prefix.
113
+ if (p === "*" || p === "") return ""; // company-wide / empty → everything
114
+ if (p.endsWith("/*")) return p.slice(0, -1); // "a/b/*" → "a/b/"
115
+ if (p.endsWith("*")) return p.slice(0, -1); // "a/b*" → "a/b"
116
+ return p; // dir prefix ("a/b/") or exact key ("a/b.md") — already a prefix
117
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Unit tests for reindexAfterSync.
3
+ *
4
+ * The function shells out to the global `qmd` binary, so every test injects a
5
+ * fake `exec` that records the calls and returns canned results — no real qmd
6
+ * is invoked. We assert on the *sequence of qmd commands* the function would
7
+ * run, which is the contract that keeps a teammate's index fresh after sync.
8
+ */
9
+
10
+ import { describe, it, expect } from "vitest";
11
+ import * as fs from "fs";
12
+ import * as os from "os";
13
+ import * as path from "path";
14
+ import { reindexAfterSync, type QmdExec } from "./qmd-reindex.js";
15
+
16
+ /** Build a fake exec that returns status 0 for everything and logs calls. */
17
+ function fakeExec(opts?: { listStdout?: string; failOn?: string }): {
18
+ exec: QmdExec;
19
+ calls: string[][];
20
+ } {
21
+ const calls: string[][] = [];
22
+ const exec: QmdExec = (args) => {
23
+ calls.push(args);
24
+ if (opts?.failOn && args[0] === opts.failOn) return { status: 1, stdout: "" };
25
+ if (args[0] === "collection" && args[1] === "list") {
26
+ return { status: 0, stdout: opts?.listStdout ?? "" };
27
+ }
28
+ return { status: 0, stdout: "" };
29
+ };
30
+ return { exec, calls };
31
+ }
32
+
33
+ describe("reindexAfterSync", () => {
34
+ it("no-ops when the path is not an HQ root", () => {
35
+ const { exec, calls } = fakeExec();
36
+ const r = reindexAfterSync("/tmp/not-hq", {
37
+ exec,
38
+ existsSync: () => false, // core/core.yaml absent
39
+ });
40
+ expect(r.qmdAvailable).toBe(false);
41
+ expect(calls).toHaveLength(0);
42
+ });
43
+
44
+ it("no-ops when qmd is unavailable (collection list errors)", () => {
45
+ const { exec, calls } = fakeExec({ failOn: "collection" });
46
+ const r = reindexAfterSync("/hq", {
47
+ exec,
48
+ existsSync: () => true, // core.yaml present
49
+ });
50
+ expect(r.qmdAvailable).toBe(false);
51
+ expect(r.updated).toBe(false);
52
+ // Only the availability probe ran, then bailed.
53
+ expect(calls).toEqual([["collection", "list"]]);
54
+ });
55
+
56
+ it("registers a missing company collection then runs an incremental update", () => {
57
+ const { exec, calls } = fakeExec({ listStdout: "Collections (1):\n\nHQ (qmd://HQ/)\n" });
58
+ const r = reindexAfterSync("/hq", {
59
+ exec,
60
+ existsSync: () => true,
61
+ readCompanies: () => ["acme"],
62
+ hasIndexableMarkdown: () => true,
63
+ });
64
+ expect(r.qmdAvailable).toBe(true);
65
+ expect(r.collectionsAdded).toEqual(["acme"]);
66
+ expect(r.updated).toBe(true);
67
+ expect(r.embedded).toBe(false);
68
+
69
+ // Expected command sequence: probe, add, context, update.
70
+ expect(calls).toEqual([
71
+ ["collection", "list"],
72
+ ["collection", "add", path.join("/hq", "companies", "acme", "knowledge"), "--name", "acme", "--mask", "**/*.md"],
73
+ ["context", "add", "qmd://acme", "Knowledge base for acme."],
74
+ ["update"],
75
+ ]);
76
+ });
77
+
78
+ it("skips collections that already exist", () => {
79
+ const { exec, calls } = fakeExec({ listStdout: "acme (qmd://acme/)\n" });
80
+ const r = reindexAfterSync("/hq", {
81
+ exec,
82
+ existsSync: () => true,
83
+ readCompanies: () => ["acme"],
84
+ hasIndexableMarkdown: () => true,
85
+ });
86
+ expect(r.collectionsAdded).toEqual([]);
87
+ // No `collection add` — straight to update.
88
+ expect(calls).toEqual([["collection", "list"], ["update"]]);
89
+ });
90
+
91
+ it("skips company dirs with no indexable markdown", () => {
92
+ const { exec, calls } = fakeExec({ listStdout: "" });
93
+ const r = reindexAfterSync("/hq", {
94
+ exec,
95
+ existsSync: () => true,
96
+ readCompanies: () => ["empty"],
97
+ hasIndexableMarkdown: () => false,
98
+ });
99
+ expect(r.collectionsAdded).toEqual([]);
100
+ expect(calls).toEqual([["collection", "list"], ["update"]]);
101
+ });
102
+
103
+ it("runs embed only when embed:true", () => {
104
+ const { exec, calls } = fakeExec({ listStdout: "" });
105
+ const r = reindexAfterSync("/hq", {
106
+ exec,
107
+ existsSync: () => true,
108
+ readCompanies: () => [],
109
+ embed: true,
110
+ });
111
+ expect(r.embedded).toBe(true);
112
+ expect(calls).toEqual([["collection", "list"], ["update"], ["embed"]]);
113
+ });
114
+
115
+ it("never throws even if exec misbehaves", () => {
116
+ const exec: QmdExec = () => {
117
+ throw new Error("boom");
118
+ };
119
+ expect(() =>
120
+ reindexAfterSync("/hq", { exec, existsSync: () => true }),
121
+ ).not.toThrow();
122
+ });
123
+
124
+ it("hasIndexableMarkdown finds a real .md and ignores INDEX.md", () => {
125
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-qmd-test-"));
126
+ try {
127
+ const hq = path.join(dir, "hq");
128
+ const kdir = path.join(hq, "companies", "acme", "knowledge");
129
+ fs.mkdirSync(kdir, { recursive: true });
130
+ fs.mkdirSync(path.join(hq, "core"), { recursive: true });
131
+ fs.writeFileSync(path.join(hq, "core", "core.yaml"), "hqVersion: 1\n");
132
+ fs.writeFileSync(path.join(kdir, "INDEX.md"), "# index\n");
133
+ fs.writeFileSync(path.join(kdir, "note.md"), "# real\n");
134
+
135
+ const { exec, calls } = fakeExec({ listStdout: "" });
136
+ const r = reindexAfterSync(hq, { exec }); // real fs walk for md detection
137
+ expect(r.collectionsAdded).toEqual(["acme"]);
138
+ expect(calls.some((c) => c[0] === "collection" && c[1] === "add")).toBe(true);
139
+ } finally {
140
+ fs.rmSync(dir, { recursive: true, force: true });
141
+ }
142
+ });
143
+ });