@indigoai-us/hq-cloud 6.12.1 → 6.12.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 (40) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +10 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner.d.ts +6 -0
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +1 -0
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/cli/share.d.ts +16 -0
  10. package/dist/cli/share.d.ts.map +1 -1
  11. package/dist/cli/share.js +62 -3
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +101 -0
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts +15 -0
  16. package/dist/cli/sync.d.ts.map +1 -1
  17. package/dist/cli/sync.js +9 -0
  18. package/dist/cli/sync.js.map +1 -1
  19. package/dist/ignore.d.ts +14 -0
  20. package/dist/ignore.d.ts.map +1 -1
  21. package/dist/ignore.js +66 -0
  22. package/dist/ignore.js.map +1 -1
  23. package/dist/ignore.test.js +43 -1
  24. package/dist/ignore.test.js.map +1 -1
  25. package/dist/object-io.d.ts.map +1 -1
  26. package/dist/object-io.js +15 -0
  27. package/dist/object-io.js.map +1 -1
  28. package/dist/object-io.test.js +52 -0
  29. package/dist/object-io.test.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/bin/sync-runner-company.ts +9 -0
  32. package/src/bin/sync-runner.test.ts +1 -0
  33. package/src/bin/sync-runner.ts +1 -0
  34. package/src/cli/share.test.ts +120 -0
  35. package/src/cli/share.ts +81 -3
  36. package/src/cli/sync.ts +26 -0
  37. package/src/ignore.test.ts +54 -1
  38. package/src/ignore.ts +73 -0
  39. package/src/object-io.test.ts +54 -0
  40. package/src/object-io.ts +18 -0
package/src/cli/share.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  PERSONAL_VAULT_JOURNAL_SLUG,
36
36
  migratePersonalVaultJournal,
37
37
  } from "../journal.js";
38
- import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
38
+ import { createIgnoreFilter, isExpectedIgnore, isWithinSizeLimit } from "../ignore.js";
39
39
  import {
40
40
  wrapFilterWithPersonalVaultDefaults,
41
41
  type PersonalVaultExclusion,
@@ -261,6 +261,7 @@ export function isMalformedVaultKey(key: string): boolean {
261
261
  export const _testing = {
262
262
  isEphemeralPath,
263
263
  EPHEMERAL_PATH_PATTERN,
264
+ wrapFilterWithIgnoreVisibility,
264
265
  };
265
266
 
266
267
  /**
@@ -734,6 +735,13 @@ export interface ShareResult {
734
735
  * its in-scope subset.
735
736
  */
736
737
  filesExcludedByScope: number;
738
+ /**
739
+ * Number of distinct hq-root-relative paths skipped by the base ignore
740
+ * filter that look like real content rather than expected build/VCS/cache
741
+ * noise. Mirrors the `count` field of the `ignore-excluded` event (emitted
742
+ * once if this is > 0).
743
+ */
744
+ filesExcludedByIgnore: number;
737
745
  /**
738
746
  * Paths (company-relative) that were detected as push conflicts. Mirrors
739
747
  * `SyncResult.conflictPaths` so push and pull surface conflicts the same
@@ -792,6 +800,31 @@ function wrapFilterWithScope(
792
800
  };
793
801
  }
794
802
 
803
+ /**
804
+ * Wrap the base ignore filter so push can observe paths it rejects before
805
+ * personal-vault policy or ACL scope wrappers add their own exclusions.
806
+ * Every base-ignore rejection increments `onAnyExcluded`; only rejections
807
+ * that do NOT match expected build/VCS/cache noise are tagged through
808
+ * `onIgnoreExcluded` for the one-shot `ignore-excluded` summary.
809
+ */
810
+ export function wrapFilterWithIgnoreVisibility(
811
+ underlying: (absPath: string, isDir?: boolean) => boolean,
812
+ hqRoot: string,
813
+ onIgnoreExcluded: (relPath: string) => void,
814
+ onAnyExcluded?: () => void,
815
+ ): (absPath: string, isDir?: boolean) => boolean {
816
+ return (absPath: string, isDir?: boolean) => {
817
+ const allowed = underlying(absPath, isDir);
818
+ if (allowed) return true;
819
+
820
+ onAnyExcluded?.();
821
+ const rel = path.relative(hqRoot, absPath).split(path.sep).join("/");
822
+ if (rel === "" || rel.startsWith("..")) return false;
823
+ if (!isExpectedIgnore(rel)) onIgnoreExcluded(rel);
824
+ return false;
825
+ };
826
+ }
827
+
795
828
  /**
796
829
  * Share local file(s) to the entity vault.
797
830
  */
@@ -857,6 +890,8 @@ interface PushRunContext {
857
890
  excludedSet: Set<string>;
858
891
  excludedById: Record<string, number>;
859
892
  scopeExcludedSet: Set<string>;
893
+ ignoreExcludedSet: Set<string>;
894
+ ignoreExcludedTotal: { value: number };
860
895
  }
861
896
 
862
897
  interface ShareCounters {
@@ -939,6 +974,18 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
939
974
  : path.join(hqRoot, "companies", ctx.slug);
940
975
 
941
976
  const ignoreFilter = createIgnoreFilter(hqRoot);
977
+ const ignoreExcludedSet = new Set<string>();
978
+ const ignoreExcludedTotal = { value: 0 };
979
+ const recordedIgnoreFilter = wrapFilterWithIgnoreVisibility(
980
+ ignoreFilter,
981
+ hqRoot,
982
+ (rel) => {
983
+ ignoreExcludedSet.add(rel);
984
+ },
985
+ () => {
986
+ ignoreExcludedTotal.value++;
987
+ },
988
+ );
942
989
  const excludedSet = new Set<string>();
943
990
  const excludedById: Record<string, number> = {};
944
991
  const onExcluded = (rel: string, match: PersonalVaultExclusion) => {
@@ -951,8 +998,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
951
998
  scopeExcludedSet.add(rel);
952
999
  };
953
1000
  const baseFilter = options.personalMode === true
954
- ? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
955
- : ignoreFilter;
1001
+ ? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
1002
+ : recordedIgnoreFilter;
956
1003
  const shouldSync = options.prefixSet !== undefined
957
1004
  ? wrapFilterWithScope(baseFilter, syncRoot, options.prefixSet, onScopeExcluded)
958
1005
  : baseFilter;
@@ -981,6 +1028,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
981
1028
  excludedSet,
982
1029
  excludedById,
983
1030
  scopeExcludedSet,
1031
+ ignoreExcludedSet,
1032
+ ignoreExcludedTotal,
984
1033
  };
985
1034
  }
986
1035
 
@@ -1494,6 +1543,20 @@ function finalizeShareJournal(run: PushRunContext): void {
1494
1543
  samplePaths,
1495
1544
  });
1496
1545
  }
1546
+
1547
+ if (run.ignoreExcludedSet.size > 0) {
1548
+ const samplePaths: string[] = [];
1549
+ for (const p of run.ignoreExcludedSet) {
1550
+ samplePaths.push(p);
1551
+ if (samplePaths.length >= 10) break;
1552
+ }
1553
+ run.emit({
1554
+ type: "ignore-excluded",
1555
+ count: run.ignoreExcludedSet.size,
1556
+ totalExcluded: run.ignoreExcludedTotal.value,
1557
+ samplePaths,
1558
+ });
1559
+ }
1497
1560
  }
1498
1561
 
1499
1562
  function throwUploadWorkerErrors(workerErrors: Error[]): void {
@@ -1528,6 +1591,7 @@ function buildShareResult(
1528
1591
  filesSuppressedByTombstone: counters.filesSuppressedByTombstone,
1529
1592
  filesExcludedByPolicy: run.excludedSet.size,
1530
1593
  filesExcludedByScope: run.scopeExcludedSet.size,
1594
+ filesExcludedByIgnore: run.ignoreExcludedSet.size,
1531
1595
  conflictPaths,
1532
1596
  aborted,
1533
1597
  };
@@ -1589,6 +1653,20 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
1589
1653
  ` Sample refused paths: ${event.samplePaths.slice(0, 5).join(", ")}${event.samplePaths.length > 5 ? ", …" : ""}\n` +
1590
1654
  ` To proceed anyway: re-run with HQ_SYNC_DELETE_BULK_OVERRIDE=1 (or propagateDeletePolicy:"all").`,
1591
1655
  );
1656
+ } else if (event.type === "ignore-excluded") {
1657
+ // The "not silent" surface: an ignore rule dropped content that doesn't
1658
+ // look like expected build/VCS/cache noise, so it never reached the vault.
1659
+ // Surface it as a WARN naming the paths so an over-broad exclusion is
1660
+ // observable rather than invisible (DEV-1791 / feedback_b9dfc7ae).
1661
+ console.warn(
1662
+ ` ! ${event.count} path${event.count === 1 ? "" : "s"} were EXCLUDED from sync by an ignore rule and did NOT reach the vault (review in case this is unintended):`,
1663
+ );
1664
+ for (const p of event.samplePaths) {
1665
+ console.warn(` · ${p}`);
1666
+ }
1667
+ if (event.count > event.samplePaths.length) {
1668
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
1669
+ }
1592
1670
  }
1593
1671
  }
1594
1672
 
package/src/cli/sync.ts CHANGED
@@ -287,6 +287,22 @@ export type SyncProgressEvent =
287
287
  count: number;
288
288
  samplePaths: string[];
289
289
  }
290
+ | {
291
+ /**
292
+ * Emitted at most ONCE per `share()` push leg when the base ignore
293
+ * filter dropped one or more NOTEWORTHY paths: content that does not
294
+ * match expected build/VCS/cache noise classes. Surfaces an over-broad
295
+ * exclusion that would otherwise drop content silently (DEV-1791).
296
+ *
297
+ * `count` is the number of distinct noteworthy paths; `totalExcluded`
298
+ * is all base-ignore rejections including expected noise; `samplePaths`
299
+ * carries up to 10 noteworthy paths. Not emitted when `count === 0`.
300
+ */
301
+ type: "ignore-excluded";
302
+ count: number;
303
+ totalExcluded: number;
304
+ samplePaths: string[];
305
+ }
290
306
  | {
291
307
  /**
292
308
  * Emitted by the PUSH leg (`share()`) once per key whose upload was
@@ -2268,5 +2284,15 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2268
2284
  if (event.count > event.samplePaths.length) {
2269
2285
  console.log(` ... and ${event.count - event.samplePaths.length} more`);
2270
2286
  }
2287
+ } else if (event.type === "ignore-excluded") {
2288
+ console.warn(
2289
+ ` ! ${event.count} path${event.count === 1 ? "" : "s"} were EXCLUDED from sync by an ignore rule and did NOT reach the vault (review in case this is unintended):`,
2290
+ );
2291
+ for (const p of event.samplePaths) {
2292
+ console.warn(` · ${p}`);
2293
+ }
2294
+ if (event.count > event.samplePaths.length) {
2295
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
2296
+ }
2271
2297
  }
2272
2298
  }
@@ -11,7 +11,31 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
11
  import * as fs from "fs";
12
12
  import * as os from "os";
13
13
  import * as path from "path";
14
- import { createIgnoreFilter } from "./ignore.js";
14
+ import { createIgnoreFilter, isExpectedIgnore } from "./ignore.js";
15
+ import { wrapFilterWithIgnoreVisibility } from "./cli/share.js";
16
+
17
+ describe("isExpectedIgnore", () => {
18
+ it("classifies top-level HQ-managed roots as expected", () => {
19
+ expect(isExpectedIgnore("repos")).toBe(true);
20
+ expect(isExpectedIgnore("repos/private/x.ts")).toBe(true);
21
+ expect(isExpectedIgnore("workspace/tmp/file.md")).toBe(true);
22
+ });
23
+
24
+ it("classifies build, VCS, cache, and known basename noise as expected", () => {
25
+ expect(isExpectedIgnore("node_modules/react/index.js")).toBe(true);
26
+ expect(isExpectedIgnore("apps/web/node_modules/react/index.js")).toBe(true);
27
+ expect(isExpectedIgnore(".git/config")).toBe(true);
28
+ expect(isExpectedIgnore(".DS_Store")).toBe(true);
29
+ expect(isExpectedIgnore("x/.env")).toBe(true);
30
+ expect(isExpectedIgnore("a/b/.hq-sync.pid")).toBe(true);
31
+ expect(isExpectedIgnore("y/foo.pyc")).toBe(true);
32
+ });
33
+
34
+ it("keeps nested repos/workspace and ordinary content noteworthy", () => {
35
+ expect(isExpectedIgnore("companies/indigo/knowledge/repos/foo/notes.md")).toBe(false);
36
+ expect(isExpectedIgnore("companies/indigo/knowledge/overview.md")).toBe(false);
37
+ });
38
+ });
15
39
 
16
40
  describe("createIgnoreFilter", () => {
17
41
  let hqRoot: string;
@@ -69,6 +93,35 @@ describe("createIgnoreFilter", () => {
69
93
  expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
70
94
  });
71
95
 
96
+ it("visibility fires on an OVER-BROAD exclusion (real filter + recorder, DEV-1791)", () => {
97
+ // Simulate the regressed over-broad bug: an un-anchored `repos/` in
98
+ // .hqignore that matches ANY nested repos segment — exactly what
99
+ // silently ate /discover knowledge under companies/*/knowledge/repos/
100
+ // before the root-anchor fix. The visibility recorder must surface the
101
+ // dropped knowledge as NOTEWORTHY while staying quiet on build noise.
102
+ fs.writeFileSync(path.join(hqRoot, ".hqignore"), "repos/\n");
103
+ const ignoreFilter = createIgnoreFilter(hqRoot);
104
+ const noteworthy: string[] = [];
105
+ let total = 0;
106
+ const recorded = wrapFilterWithIgnoreVisibility(
107
+ ignoreFilter,
108
+ hqRoot,
109
+ (rel) => { noteworthy.push(rel); },
110
+ () => { total++; },
111
+ );
112
+
113
+ // Knowledge under a nested repos/ is dropped by the over-broad rule and
114
+ // is genuine content → captured as noteworthy.
115
+ expect(recorded(path.join(hqRoot, "companies/indigo/knowledge/repos/foo/notes.md"))).toBe(false);
116
+ // Build noise is also excluded but is EXPECTED → counted, not noteworthy.
117
+ expect(recorded(path.join(hqRoot, "node_modules/react/index.js"))).toBe(false);
118
+ // Ordinary knowledge that the filter ALLOWS fires nothing.
119
+ expect(recorded(path.join(hqRoot, "companies/indigo/knowledge/overview.md"))).toBe(true);
120
+
121
+ expect(noteworthy).toEqual(["companies/indigo/knowledge/repos/foo/notes.md"]);
122
+ expect(total).toBe(2); // both exclusions counted; only one is noteworthy
123
+ });
124
+
72
125
  it("v15+ layout (core/core.yaml present): root core.yaml is ignored, nested core/core.yaml syncs", () => {
73
126
  // On v15+ the scaffold/version manifest lives at `core/core.yaml` (a real
74
127
  // project file that DOES sync). Any root-level `core.yaml` here is a stale
package/src/ignore.ts CHANGED
@@ -209,6 +209,79 @@ function segmentToRegex(seg: string): RegExp {
209
209
  return new RegExp(`^${body}$`);
210
210
  }
211
211
 
212
+ export const EXPECTED_IGNORE_SEGMENTS = new Set([
213
+ ".git",
214
+ "node_modules",
215
+ ".pnpm-store",
216
+ "dist",
217
+ "build",
218
+ ".next",
219
+ ".nuxt",
220
+ ".svelte-kit",
221
+ ".turbo",
222
+ ".parcel-cache",
223
+ ".vite",
224
+ "coverage",
225
+ "target",
226
+ "__pycache__",
227
+ ".pytest_cache",
228
+ ".mypy_cache",
229
+ ".ruff_cache",
230
+ ".venv",
231
+ "venv",
232
+ "vendor",
233
+ "out",
234
+ ".cache",
235
+ "tmp",
236
+ ".tmp",
237
+ ".hq",
238
+ ".claude",
239
+ ]);
240
+
241
+ /**
242
+ * Classify a base-ignore rejection as expected noise vs noteworthy content.
243
+ *
244
+ * `true` means the ignored path belongs to an expected by-design exclusion
245
+ * class: HQ-managed top-level roots, build/VCS/cache/machine-local segments,
246
+ * or known generated/noise basenames. `false` means the rejected path looks
247
+ * like real user content and should be surfaced by push observability.
248
+ *
249
+ * Top-level `repos/` and `workspace/` are expected, but nested segments named
250
+ * `repos` or `workspace` are deliberately noteworthy: that is the DEV-1791
251
+ * regression signal this classifier exists to preserve.
252
+ */
253
+ export function isExpectedIgnore(relPath: string): boolean {
254
+ if (
255
+ relPath === "repos" ||
256
+ relPath.startsWith("repos/") ||
257
+ relPath === "workspace" ||
258
+ relPath.startsWith("workspace/")
259
+ ) {
260
+ return true;
261
+ }
262
+
263
+ const segments = relPath.split("/");
264
+ for (const segment of segments) {
265
+ if (EXPECTED_IGNORE_SEGMENTS.has(segment)) return true;
266
+ }
267
+
268
+ const basename = segments[segments.length - 1] ?? "";
269
+ return (
270
+ basename === ".DS_Store" ||
271
+ basename === "Thumbs.db" ||
272
+ basename === "company.yaml" ||
273
+ basename === "INDEX.md" ||
274
+ basename === "modules.lock" ||
275
+ basename.endsWith(".pyc") ||
276
+ basename.endsWith(".class") ||
277
+ basename.endsWith(".pid") ||
278
+ basename.startsWith(".hq-") ||
279
+ basename === ".env" ||
280
+ basename.startsWith(".env.") ||
281
+ basename === ".mcp.json"
282
+ );
283
+ }
284
+
212
285
  export function createIgnoreFilter(
213
286
  hqRoot: string,
214
287
  ): (filePath: string, isDir?: boolean) => boolean {
@@ -19,6 +19,7 @@ import {
19
19
  } from "./object-io.js";
20
20
  import type { EntityContext } from "./types.js";
21
21
  import type { PresignResultRow, VaultListedObject } from "./vault-client.js";
22
+ import { isAccessDenied } from "./sync-core.js";
22
23
 
23
24
  const COMPANY = "cmp_test";
24
25
 
@@ -205,6 +206,59 @@ describe("PresignObjectIO.putObject", () => {
205
206
  expect(fetchMock).not.toHaveBeenCalled();
206
207
  });
207
208
 
209
+ it("a write denial (FILES_PRESIGN_FORBIDDEN) throws the access-denied shape so the push skips it like a denied GET", async () => {
210
+ const { vault, setPresign } = makeVault();
211
+ // The server refuses to MINT a PUT URL for a key the caller can't write
212
+ // (private-by-default: members hold read-only on a shared knowledge prefix).
213
+ setPresign([
214
+ {
215
+ key: "knowledge/clients/acme/brief.md",
216
+ op: "put",
217
+ error: "Caller lacks write permission on 'knowledge/clients/acme/brief.md'",
218
+ code: "FILES_PRESIGN_FORBIDDEN",
219
+ },
220
+ ]);
221
+ const io = new PresignObjectIO(vault, COMPANY);
222
+ let caught: unknown;
223
+ await io
224
+ .putObject({
225
+ key: "knowledge/clients/acme/brief.md",
226
+ body: Buffer.from("z"),
227
+ contentType: "text/markdown",
228
+ })
229
+ .catch((e) => {
230
+ caught = e;
231
+ });
232
+ // Same shape a denied GET/HEAD already throws (name === "Forbidden"), so the
233
+ // share.ts push catch routes it through `isAccessDenied` into the silent
234
+ // per-path skip (scopeExcludedSet) + the "N paths skipped — outside your
235
+ // granted access" aggregate, instead of a fatal `type:"error"` that exits
236
+ // the runner non-zero and floods Sentry. This is the PUT/GET symmetry that
237
+ // fixes the fleet-wide presign-put-denied crash-loop.
238
+ expect((caught as Error).name).toBe("Forbidden");
239
+ expect(isAccessDenied(caught)).toBe(true);
240
+ // Denied at mint time — the byte-moving PUT is never attempted.
241
+ expect(fetchMock).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it("a non-authorization presign failure stays a generic (fatal) error — not silently skipped", async () => {
245
+ const { vault, setPresign } = makeVault();
246
+ setPresign([
247
+ { key: "secret/x", op: "put", error: "boom", code: "PRESIGN_INTERNAL" },
248
+ ]);
249
+ const io = new PresignObjectIO(vault, COMPANY);
250
+ let caught: unknown;
251
+ await io
252
+ .putObject({ key: "secret/x", body: Buffer.from("z"), contentType: "x" })
253
+ .catch((e) => {
254
+ caught = e;
255
+ });
256
+ // Only `*_FORBIDDEN` authorization denials are reclassified; a real backend
257
+ // failure must still surface loudly so it is not masked as an expected skip.
258
+ expect(isAccessDenied(caught)).toBe(false);
259
+ expect((caught as Error).message).toMatch(/denied for secret\/x.*PRESIGN_INTERNAL/);
260
+ });
261
+
208
262
  it("throws when the PUT itself fails", async () => {
209
263
  const { vault, setPresign } = makeVault();
210
264
  setPresign([{ key: "k", op: "put", url: "https://s3/put" }]);
package/src/object-io.ts CHANGED
@@ -354,6 +354,24 @@ function firstRowOrThrow(
354
354
  throw new Error(`presign ${op} returned no row for ${key}`);
355
355
  }
356
356
  if (row.error || !row.url) {
357
+ // An authorization denial (server `*_FORBIDDEN` code — e.g.
358
+ // FILES_PRESIGN_FORBIDDEN when the caller lacks write on a shared prefix,
359
+ // or FILES_RAW_FORBIDDEN) must throw the SAME `name: "Forbidden"` shape the
360
+ // GET/HEAD path uses (see {@link accessDeniedError}) so it routes through
361
+ // the `isAccessDenied` skip in sync.ts / share.ts. Without this, a denied
362
+ // PUT presign fell to the generic Error below — `isAccessDenied` returned
363
+ // false — and the per-file ACL skip the GET path handles gracefully became
364
+ // a FATAL sync error: the crash-loop + Sentry flood behind the fleet-wide
365
+ // "presign put denied" incident (members pushing net-new keys to a
366
+ // company-wide-read prefix under private-by-default). The denial is
367
+ // expected; only the fatal handling of it was the bug.
368
+ if (typeof row.code === "string" && row.code.endsWith("_FORBIDDEN")) {
369
+ throw accessDeniedError(
370
+ key,
371
+ `no write access to '${key}' (server ${op} denied: ${row.code}) — ` +
372
+ `ask an admin to grant write on this shared prefix`,
373
+ );
374
+ }
357
375
  throw new Error(
358
376
  `presign ${op} denied for ${key}: ${row.error ?? "no url"}${
359
377
  row.code ? ` (${row.code})` : ""