@indigoai-us/hq-cli 5.25.1 → 5.28.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.
@@ -4,6 +4,13 @@
4
4
  * Uses a tmpdir fixture (same pattern as cloud-demote.test.ts) — vitest +
5
5
  * real fs — so the symmetric-diff + dirty-classification logic is exercised
6
6
  * end-to-end against an actual journal + filesystem.
7
+ *
8
+ * NAMESPACE CONTRACT (the bug these tests previously encoded): files are
9
+ * written on disk at their hq-root-relative path (`companies/<slug>/...`),
10
+ * but `buildNarrowPlan` emits — and the journal + grants endpoint use —
11
+ * COMPANY-RELATIVE keys (`meetings/a.md`). The `key()` helper derives the
12
+ * company-relative form from the on-disk path so journals, prefix sets, and
13
+ * expectations all speak the same namespace the real server does.
7
14
  */
8
15
 
9
16
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -30,11 +37,23 @@ function writeFile(rel: string, contents: string): { abs: string; rel: string }
30
37
  return { abs, rel };
31
38
  }
32
39
 
40
+ /**
41
+ * Strip the `companies/<slug>/` prefix from an on-disk hq-root-relative path
42
+ * to get the COMPANY-RELATIVE key — the namespace `buildNarrowPlan` emits and
43
+ * the journal + grants endpoint use.
44
+ */
45
+ function key(rel: string): string {
46
+ return rel.replace(/^companies\/[^/]+\//, "");
47
+ }
48
+
33
49
  function sha256(s: string): string {
34
50
  return crypto.createHash("sha256").update(s).digest("hex");
35
51
  }
36
52
 
37
- /** Build a journal that "knows about" the given files at their current hash + mtime. */
53
+ /**
54
+ * Build a journal that "knows about" the given files at their current hash +
55
+ * mtime. Journal keys are COMPANY-RELATIVE (via `key()`), matching the engine.
56
+ */
38
57
  function journalFromFiles(
39
58
  files: Array<{ rel: string; contents: string; syncedAt?: string }>,
40
59
  ): SyncJournal {
@@ -45,7 +64,7 @@ function journalFromFiles(
45
64
  pulls: [],
46
65
  };
47
66
  for (const f of files) {
48
- j.files[f.rel] = {
67
+ j.files[key(f.rel)] = {
49
68
  hash: sha256(f.contents),
50
69
  size: Buffer.byteLength(f.contents),
51
70
  // Stamp syncedAt slightly in the future so on-disk mtime <= syncedAt
@@ -97,13 +116,13 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
97
116
  const plan = buildNarrowPlan({
98
117
  hqRoot: tmpRoot,
99
118
  companySlug: "acme",
100
- prospectivePrefixSet: ["companies/acme/meetings/"],
119
+ prospectivePrefixSet: ["meetings/"],
101
120
  journal,
102
121
  });
103
122
 
104
- expect(plan.staying.map((f) => f.relPath)).toEqual([staying.rel]);
105
- expect(plan.clean.map((f) => f.relPath)).toEqual([cleanOrphan.rel]);
106
- expect(plan.dirty.map((f) => f.relPath)).toEqual([dirtyOrphan.rel]);
123
+ expect(plan.staying.map((f) => f.relPath)).toEqual([key(staying.rel)]);
124
+ expect(plan.clean.map((f) => f.relPath)).toEqual([key(cleanOrphan.rel)]);
125
+ expect(plan.dirty.map((f) => f.relPath)).toEqual([key(dirtyOrphan.rel)]);
107
126
  expect(plan.dirty[0].reason).toBe("hash-mismatch");
108
127
 
109
128
  expect(plan.totalStayingCount).toBe(1);
@@ -117,10 +136,10 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
117
136
  const plan = buildNarrowPlan({
118
137
  hqRoot: tmpRoot,
119
138
  companySlug: "acme",
120
- prospectivePrefixSet: ["companies/acme/meetings/"],
139
+ prospectivePrefixSet: ["meetings/"],
121
140
  journal: journalFromFiles([]),
122
141
  });
123
- expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
142
+ expect(plan.dirty.map((f) => f.relPath)).toEqual([key(orphan.rel)]);
124
143
  expect(plan.dirty[0].reason).toBe("not-in-journal");
125
144
  });
126
145
 
@@ -130,16 +149,16 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
130
149
  { rel: orphan.rel, contents: "still here" },
131
150
  ]);
132
151
  // Mark the entry tombstoned post-hoc.
133
- journal.files[orphan.rel]!.removedAt = new Date().toISOString();
134
- journal.files[orphan.rel]!.removedReason = "scope_shrink";
152
+ journal.files[key(orphan.rel)]!.removedAt = new Date().toISOString();
153
+ journal.files[key(orphan.rel)]!.removedReason = "scope_shrink";
135
154
 
136
155
  const plan = buildNarrowPlan({
137
156
  hqRoot: tmpRoot,
138
157
  companySlug: "acme",
139
- prospectivePrefixSet: ["companies/acme/meetings/"],
158
+ prospectivePrefixSet: ["meetings/"],
140
159
  journal,
141
160
  });
142
- expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
161
+ expect(plan.dirty.map((f) => f.relPath)).toEqual([key(orphan.rel)]);
143
162
  expect(plan.dirty[0].reason).toBe("not-in-journal");
144
163
  });
145
164
 
@@ -151,10 +170,10 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
151
170
  const plan = buildNarrowPlan({
152
171
  hqRoot: tmpRoot,
153
172
  companySlug: "acme",
154
- prospectivePrefixSet: ["companies/acme/meetings/"],
173
+ prospectivePrefixSet: ["meetings/"],
155
174
  journal,
156
175
  });
157
- expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
176
+ expect(plan.dirty.map((f) => f.relPath)).toEqual([key(orphan.rel)]);
158
177
  expect(plan.dirty[0].reason).toBe("modified-after-sync");
159
178
  });
160
179
 
@@ -166,7 +185,7 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
166
185
  const plan = buildNarrowPlan({
167
186
  hqRoot: tmpRoot,
168
187
  companySlug: "acme",
169
- prospectivePrefixSet: ["companies/acme/meetings/"],
188
+ prospectivePrefixSet: ["meetings/"],
170
189
  journal: journalFromFiles([
171
190
  { rel: a.rel, contents: "a" },
172
191
  { rel: b.rel, contents: "b" },
@@ -175,15 +194,15 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
175
194
  });
176
195
 
177
196
  const stayingPaths = plan.staying.map((f) => f.relPath).sort();
178
- expect(stayingPaths).toEqual([a.rel, b.rel].sort());
179
- expect(plan.clean.map((f) => f.relPath)).toEqual([c.rel]);
197
+ expect(stayingPaths).toEqual([key(a.rel), key(b.rel)].sort());
198
+ expect(plan.clean.map((f) => f.relPath)).toEqual([key(c.rel)]);
180
199
  });
181
200
 
182
201
  it("returns an empty plan when the company folder is missing", () => {
183
202
  const plan = buildNarrowPlan({
184
203
  hqRoot: tmpRoot,
185
204
  companySlug: "ghost",
186
- prospectivePrefixSet: ["companies/ghost/"],
205
+ prospectivePrefixSet: [""],
187
206
  journal: journalFromFiles([]),
188
207
  });
189
208
  expect(plan.totalStayingCount).toBe(0);
@@ -214,7 +233,8 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
214
233
  const plan = buildNarrowPlan({
215
234
  hqRoot: tmpRoot,
216
235
  companySlug: "acme",
217
- prospectivePrefixSet: ["companies/acme/"],
236
+ // Empty-string prefix = "covers everything" per isCoveredByAny.
237
+ prospectivePrefixSet: [""],
218
238
  journal: journalFromFiles([]),
219
239
  });
220
240
  expect(plan.totalStayingCount).toBe(2);
@@ -223,7 +243,7 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
223
243
  // both are staying, regardless of journal state
224
244
  expect(
225
245
  plan.staying.map((f) => f.relPath).sort(),
226
- ).toEqual([a.rel, b.rel].sort());
246
+ ).toEqual([key(a.rel), key(b.rel)].sort());
227
247
  });
228
248
  });
229
249
 
@@ -43,7 +43,11 @@ export type DirtyReason =
43
43
  | "stat-error";
44
44
 
45
45
  export interface NarrowFile {
46
- /** Path relative to `hqRoot` (matches journal key + S3 key naming). */
46
+ /**
47
+ * COMPANY-RELATIVE path (e.g. `meetings/a.md`) — the canonical namespace
48
+ * shared by the per-company journal keys, the vault S3 keys, and the
49
+ * server's explicit-grant paths. NOT hq-root-relative.
50
+ */
47
51
  relPath: string;
48
52
  /** Absolute path on disk (convenience for the CLI delete loop). */
49
53
  absPath: string;
@@ -79,7 +83,8 @@ export interface BuildNarrowPlanInput {
79
83
  /**
80
84
  * Coalesced prospective `shared`-mode prefix set (the result of running
81
85
  * the caller's explicit grants through `coalescePrefixes`). Prefixes are
82
- * hq-root-relative (e.g. `companies/indigo/meetings/`).
86
+ * COMPANY-RELATIVE (e.g. `meetings/`) — the namespace the grants endpoint
87
+ * returns and the namespace `relPath` is now computed in.
83
88
  */
84
89
  prospectivePrefixSet: readonly string[];
85
90
  /**
@@ -120,7 +125,16 @@ export function buildNarrowPlan(input: BuildNarrowPlanInput): NarrowPlan {
120
125
  return emptyPlan();
121
126
  }
122
127
 
123
- walkLocal(walkRoot, hqRoot, (file) => {
128
+ // Walk with `walkRoot` as the rel-root so each file's `relPath` is
129
+ // COMPANY-RELATIVE (e.g. `meetings/a.md`) — the same namespace as the
130
+ // server's explicit-grant paths, the per-company journal keys, and the
131
+ // hq-cloud sync engine's `RemoteFile.key`. Computing it relative to `hqRoot`
132
+ // (the old behavior) produced `companies/<slug>/meetings/a.md`, which
133
+ // matched neither the company-relative grants nor the journal keys — so
134
+ // every file fell out of `prospectivePrefixSet` AND missed its journal
135
+ // entry, making narrow flag the entire tree as dirty orphans. See the
136
+ // namespace contract in hq-cloud `scope-shrink.ts` / `prefix-coalesce.ts`.
137
+ walkLocal(walkRoot, walkRoot, (file) => {
124
138
  if (isCoveredByAny(file.relPath, prospectivePrefixSet)) {
125
139
  staying.push(file);
126
140
  return;
@@ -170,7 +184,9 @@ function emptyPlan(): NarrowPlan {
170
184
  */
171
185
  function walkLocal(
172
186
  dir: string,
173
- hqRoot: string,
187
+ // Rel-root for `relPath` — the company walk root (`<hqRoot>/companies/<slug>`),
188
+ // so emitted `relPath`s are company-relative. Fixed across recursion.
189
+ relRoot: string,
174
190
  emit: (file: NarrowFile) => void,
175
191
  ): void {
176
192
  let entries: fs.Dirent[];
@@ -186,7 +202,7 @@ function walkLocal(
186
202
 
187
203
  for (const entry of entries) {
188
204
  const absPath = path.join(dir, entry.name);
189
- const relPath = path.relative(hqRoot, absPath);
205
+ const relPath = path.relative(relRoot, absPath);
190
206
 
191
207
  if (entry.isSymbolicLink()) {
192
208
  // Record the link as a file-like entry. Don't descend — narrow is
@@ -204,7 +220,7 @@ function walkLocal(
204
220
  }
205
221
 
206
222
  if (entry.isDirectory()) {
207
- walkLocal(absPath, hqRoot, emit);
223
+ walkLocal(absPath, relRoot, emit);
208
224
  continue;
209
225
  }
210
226