@indigoai-us/hq-cloud 6.12.1 → 6.12.3
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.
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +10 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-events.d.ts +18 -0
- package/dist/bin/sync-runner-events.d.ts.map +1 -1
- package/dist/bin/sync-runner-events.js +26 -0
- package/dist/bin/sync-runner-events.js.map +1 -1
- package/dist/bin/sync-runner-events.test.d.ts +2 -0
- package/dist/bin/sync-runner-events.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-events.test.js +90 -0
- package/dist/bin/sync-runner-events.test.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +6 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +10 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +1 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +25 -2
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +51 -1
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/share.d.ts +16 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +62 -3
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +101 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +15 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +9 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/ignore.d.ts +14 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +66 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +43 -1
- package/dist/ignore.test.js.map +1 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +15 -0
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +52 -0
- package/dist/object-io.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +9 -0
- package/src/bin/sync-runner-events.test.ts +112 -0
- package/src/bin/sync-runner-events.ts +30 -0
- package/src/bin/sync-runner.test.ts +1 -0
- package/src/bin/sync-runner.ts +14 -4
- package/src/cli/reindex.test.ts +68 -1
- package/src/cli/reindex.ts +27 -2
- package/src/cli/share.test.ts +120 -0
- package/src/cli/share.ts +81 -3
- package/src/cli/sync.ts +26 -0
- package/src/ignore.test.ts +54 -1
- package/src/ignore.ts +73 -0
- package/src/object-io.test.ts +54 -0
- package/src/object-io.ts +18 -0
package/src/cli/reindex.test.ts
CHANGED
|
@@ -24,6 +24,11 @@ const hoisted = vi.hoisted(() => ({
|
|
|
24
24
|
// lets a test simulate a mkdir failure at a NON-wrapper site (e.g. the
|
|
25
25
|
// `.claude/skills` parent or a `core/<type>` mirror dir).
|
|
26
26
|
failMkdirContaining: "" as string,
|
|
27
|
+
// HQ-CLI-4: when set, any symlink whose LINK path contains this substring
|
|
28
|
+
// throws EPERM — simulates Windows WITHOUT Developer Mode, where symlink
|
|
29
|
+
// creation is a privileged operation. Off by default so other tests see the
|
|
30
|
+
// real `symlinkSync`.
|
|
31
|
+
failSymlinkContaining: "" as string,
|
|
27
32
|
}));
|
|
28
33
|
vi.mock("fs", async (importOriginal) => {
|
|
29
34
|
const actual = await importOriginal<typeof import("fs")>();
|
|
@@ -40,7 +45,24 @@ vi.mock("fs", async (importOriginal) => {
|
|
|
40
45
|
}
|
|
41
46
|
return (actual.mkdirSync as (p: unknown, opts: unknown) => unknown)(p, opts);
|
|
42
47
|
}) as typeof actual.mkdirSync;
|
|
43
|
-
const
|
|
48
|
+
const symlinkSync = ((target: unknown, linkPath: unknown, type?: unknown) => {
|
|
49
|
+
if (
|
|
50
|
+
hoisted.failSymlinkContaining !== "" &&
|
|
51
|
+
typeof linkPath === "string" &&
|
|
52
|
+
linkPath.includes(hoisted.failSymlinkContaining)
|
|
53
|
+
) {
|
|
54
|
+
throw Object.assign(
|
|
55
|
+
new Error(`EPERM: operation not permitted, symlink '${target}' -> '${linkPath}'`),
|
|
56
|
+
{ code: "EPERM" },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return (actual.symlinkSync as (t: unknown, l: unknown, ty?: unknown) => unknown)(
|
|
60
|
+
target,
|
|
61
|
+
linkPath,
|
|
62
|
+
type,
|
|
63
|
+
);
|
|
64
|
+
}) as typeof actual.symlinkSync;
|
|
65
|
+
const patched = { ...actual, mkdirSync, symlinkSync };
|
|
44
66
|
return { ...patched, default: patched };
|
|
45
67
|
});
|
|
46
68
|
|
|
@@ -307,4 +329,49 @@ describe("reindex", () => {
|
|
|
307
329
|
// The mirror for the failed type is skipped; skill surfacing still happened.
|
|
308
330
|
expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
|
|
309
331
|
});
|
|
332
|
+
|
|
333
|
+
// ── HQ-CLI-4: symlink creation is privileged on Windows. Without Developer
|
|
334
|
+
// Mode, fs.symlinkSync throws EPERM. The HQ-B0 work guarded every mkdir
|
|
335
|
+
// site but left the symlink sites unguarded, so `hq reindex` still aborted
|
|
336
|
+
// on Windows. safeSymlink must degrade gracefully like safeMkdir.
|
|
337
|
+
it("does not abort reindex when a skill-wrapper symlink fails with EPERM (Windows no Developer Mode)", () => {
|
|
338
|
+
writeSkill("core/skills/demo");
|
|
339
|
+
|
|
340
|
+
hoisted.failSymlinkContaining = path.join(".claude", "skills");
|
|
341
|
+
try {
|
|
342
|
+
// Every per-file wrapper symlink throws EPERM, but reindex must still
|
|
343
|
+
// complete cleanly (exit 0) rather than crash + flood Sentry.
|
|
344
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
345
|
+
} finally {
|
|
346
|
+
hoisted.failSymlinkContaining = "";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The wrapper DIR was still created; the symlink inside was just skipped.
|
|
350
|
+
expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
|
|
351
|
+
expect(
|
|
352
|
+
fs.existsSync(path.join(root, ".claude/skills/core:demo/SKILL.md")),
|
|
353
|
+
).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("does not abort reindex when a personal-mirror symlink fails with EPERM, and other work still runs", () => {
|
|
357
|
+
writeSkill("core/skills/demo");
|
|
358
|
+
fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
|
|
359
|
+
fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
|
|
360
|
+
|
|
361
|
+
// Fail ONLY the core/<type> personal-mirror symlinks; wrapper symlinks
|
|
362
|
+
// succeed, proving the skip is scoped and the reindex completes.
|
|
363
|
+
hoisted.failSymlinkContaining = path.join("core", "knowledge");
|
|
364
|
+
try {
|
|
365
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
366
|
+
} finally {
|
|
367
|
+
hoisted.failSymlinkContaining = "";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The personal mirror was skipped (EPERM)...
|
|
371
|
+
expect(fs.existsSync(path.join(root, "core/knowledge/my-kb"))).toBe(false);
|
|
372
|
+
// ...but unaffected work still happened: the skill wrapper + its symlink.
|
|
373
|
+
expect(
|
|
374
|
+
fs.lstatSync(path.join(root, ".claude/skills/core:demo/SKILL.md")).isSymbolicLink(),
|
|
375
|
+
).toBe(true);
|
|
376
|
+
});
|
|
310
377
|
});
|
package/src/cli/reindex.ts
CHANGED
|
@@ -154,6 +154,31 @@ function safeMkdir(dir: string, label: string): boolean {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// Create a symlink, tolerating platform-specific failures instead of aborting
|
|
158
|
+
// the whole reindex — the symlink sibling of safeMkdir. The classic case
|
|
159
|
+
// (HQ-CLI-4) is Windows WITHOUT Developer Mode (or an elevated shell), where
|
|
160
|
+
// symlink creation is a privileged operation and `fs.symlinkSync` throws EPERM
|
|
161
|
+
// (Win32 ERROR_PRIVILEGE_NOT_HELD). A single symlink we can't create should
|
|
162
|
+
// degrade gracefully — warn with an actionable hint and skip just that link so
|
|
163
|
+
// the rest of the reindex (other wrappers, mirrors, registry regen) still runs.
|
|
164
|
+
// reindex is idempotent and re-run on hooks, so a later run re-creates the link
|
|
165
|
+
// once the privilege is granted. Returns true on success, false on skip.
|
|
166
|
+
function safeSymlink(target: string, linkPath: string, label: string): boolean {
|
|
167
|
+
try {
|
|
168
|
+
fs.symlinkSync(target, linkPath);
|
|
169
|
+
return true;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
172
|
+
warn(
|
|
173
|
+
`reindex: could not create ${label} '${linkPath}' -> '${target}' ` +
|
|
174
|
+
`(${code ?? "error"}: ${(err as Error).message}); skipping. On Windows, ` +
|
|
175
|
+
`creating symlinks needs Developer Mode (Settings -> Privacy & security ` +
|
|
176
|
+
`-> For developers) or an elevated shell.`,
|
|
177
|
+
);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
157
182
|
// --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
|
|
158
183
|
// Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
|
|
159
184
|
function isLegacyCommandTarget(t: string): boolean {
|
|
@@ -325,7 +350,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
325
350
|
continue;
|
|
326
351
|
}
|
|
327
352
|
|
|
328
|
-
|
|
353
|
+
safeSymlink(relativeTarget, linkPath, `.claude/skills/${wrapperName}/${entry}`);
|
|
329
354
|
}
|
|
330
355
|
|
|
331
356
|
// Prune symlinks in the wrapper whose source entry no longer exists.
|
|
@@ -424,7 +449,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
424
449
|
continue;
|
|
425
450
|
}
|
|
426
451
|
|
|
427
|
-
|
|
452
|
+
safeSymlink(relativeTarget, linkPath, `core/${type}/${entry}`);
|
|
428
453
|
}
|
|
429
454
|
}
|
|
430
455
|
|
package/src/cli/share.test.ts
CHANGED
|
@@ -71,6 +71,39 @@ function makeEntityContext(overrides: Partial<EntityContext> = {}): EntityContex
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
describe("wrapFilterWithIgnoreVisibility", () => {
|
|
75
|
+
it("records noteworthy base-ignore exclusions while counting all rejections", () => {
|
|
76
|
+
const hqRoot = path.join("/tmp", "hqroot");
|
|
77
|
+
const noteworthy: string[] = [];
|
|
78
|
+
let total = 0;
|
|
79
|
+
const underlying = (absPath: string): boolean =>
|
|
80
|
+
!absPath.includes(`${path.sep}repos${path.sep}`) &&
|
|
81
|
+
!absPath.includes(`${path.sep}node_modules${path.sep}`);
|
|
82
|
+
const wrapped = shareTesting.wrapFilterWithIgnoreVisibility(
|
|
83
|
+
underlying,
|
|
84
|
+
hqRoot,
|
|
85
|
+
(rel) => {
|
|
86
|
+
noteworthy.push(rel);
|
|
87
|
+
},
|
|
88
|
+
() => {
|
|
89
|
+
total++;
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(wrapped(path.join(hqRoot, "companies/indigo/knowledge/notes.md"))).toBe(true);
|
|
94
|
+
expect(total).toBe(0);
|
|
95
|
+
expect(noteworthy).toEqual([]);
|
|
96
|
+
|
|
97
|
+
expect(wrapped(path.join(hqRoot, "companies/indigo/knowledge/repos/foo.md"))).toBe(false);
|
|
98
|
+
expect(total).toBe(1);
|
|
99
|
+
expect(noteworthy).toEqual(["companies/indigo/knowledge/repos/foo.md"]);
|
|
100
|
+
|
|
101
|
+
expect(wrapped(path.join(hqRoot, "node_modules/x"))).toBe(false);
|
|
102
|
+
expect(total).toBe(2);
|
|
103
|
+
expect(noteworthy).toEqual(["companies/indigo/knowledge/repos/foo.md"]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
74
107
|
const mockEntity = {
|
|
75
108
|
uid: "cmp_01ABCDEF",
|
|
76
109
|
slug: "acme",
|
|
@@ -993,6 +1026,92 @@ describe("share", () => {
|
|
|
993
1026
|
expect(putKeys).not.toContain("projects/.gitkeep");
|
|
994
1027
|
});
|
|
995
1028
|
|
|
1029
|
+
it("push surfaces an OVER-BROAD ignore exclusion via ignore-excluded instead of dropping it silently (feedback_b9dfc7ae)", async () => {
|
|
1030
|
+
// Regression for the silent-drop failure mode behind DEV-1791: an
|
|
1031
|
+
// over-broad ignore rule (here a deliberately un-anchored `repos/` in
|
|
1032
|
+
// .hqignore) eats /discover knowledge under companies/*/knowledge/repos/.
|
|
1033
|
+
// The discriminator fix stops the over-broad MATCH; this test pins the
|
|
1034
|
+
// OBSERVABILITY: when an ignore rule IS over-broad, the dropped content
|
|
1035
|
+
// is surfaced as a noteworthy `ignore-excluded` event (count + sample)
|
|
1036
|
+
// rather than vanishing. Build noise stays unsurfaced.
|
|
1037
|
+
fs.writeFileSync(path.join(tmpDir, ".hqignore"), "repos/\n");
|
|
1038
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1039
|
+
fs.mkdirSync(path.join(companyRoot, "knowledge", "repos", "dev-docs"), { recursive: true });
|
|
1040
|
+
fs.mkdirSync(path.join(companyRoot, "node_modules", "left-pad"), { recursive: true });
|
|
1041
|
+
fs.writeFileSync(path.join(companyRoot, "knowledge", "overview.md"), "# kb\n");
|
|
1042
|
+
fs.writeFileSync(path.join(companyRoot, "knowledge", "repos", "dev-docs", "notes.md"), "discover knowledge\n");
|
|
1043
|
+
fs.writeFileSync(path.join(companyRoot, "node_modules", "left-pad", "index.js"), "module.exports = 1\n");
|
|
1044
|
+
|
|
1045
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
1046
|
+
|
|
1047
|
+
const events: Array<{ type?: string; path?: string; count?: number; totalExcluded?: number; samplePaths?: string[] }> = [];
|
|
1048
|
+
const result = await share({
|
|
1049
|
+
paths: [companyRoot],
|
|
1050
|
+
company: "acme",
|
|
1051
|
+
vaultConfig: mockConfig,
|
|
1052
|
+
hqRoot: tmpDir,
|
|
1053
|
+
onEvent: (e) => events.push(e as { type?: string }),
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
// The legitimate knowledge file uploads; the over-broadly-excluded
|
|
1057
|
+
// knowledge under knowledge/repos/ does NOT.
|
|
1058
|
+
const uploadedPaths = events
|
|
1059
|
+
.filter((e) => e.type === "progress")
|
|
1060
|
+
.map((e) => e.path)
|
|
1061
|
+
.sort();
|
|
1062
|
+
expect(uploadedPaths).toEqual(["knowledge/overview.md"]);
|
|
1063
|
+
|
|
1064
|
+
// The drop is now VISIBLE: one noteworthy path surfaced, with the
|
|
1065
|
+
// knowledge/repos/ file named; node_modules counted but NOT noteworthy.
|
|
1066
|
+
expect(result.filesExcludedByIgnore).toBe(1);
|
|
1067
|
+
const ignoreEv = events.find((e) => e.type === "ignore-excluded") as
|
|
1068
|
+
| { count: number; totalExcluded: number; samplePaths: string[] }
|
|
1069
|
+
| undefined;
|
|
1070
|
+
expect(ignoreEv).toBeDefined();
|
|
1071
|
+
expect(ignoreEv!.count).toBe(1);
|
|
1072
|
+
expect(ignoreEv!.samplePaths).toEqual(["companies/acme/knowledge/repos"]);
|
|
1073
|
+
expect(ignoreEv!.totalExcluded).toBeGreaterThanOrEqual(2); // repos/ dir + node_modules/ dir
|
|
1074
|
+
expect(ignoreEv!.samplePaths).not.toContain("companies/acme/node_modules");
|
|
1075
|
+
// No upload for the dropped subtree, no error, clean completion.
|
|
1076
|
+
const putKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
|
|
1077
|
+
expect(putKeys).toEqual(["knowledge/overview.md"]);
|
|
1078
|
+
expect(result.aborted).toBe(false);
|
|
1079
|
+
expect(events.some((e) => e.type === "error")).toBe(false);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it("default console logger prints a WARN naming the silently-excluded path (no onEvent)", async () => {
|
|
1083
|
+
// The user-facing CLI surface: with no onEvent, share() falls back to the
|
|
1084
|
+
// default console logger. An over-broad ignore drop must produce a visible
|
|
1085
|
+
// WARN line naming the path — the whole point of "not silent". Spy on
|
|
1086
|
+
// console.warn rather than passing onEvent (which would suppress it).
|
|
1087
|
+
fs.writeFileSync(path.join(tmpDir, ".hqignore"), "repos/\n");
|
|
1088
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1089
|
+
fs.mkdirSync(path.join(companyRoot, "knowledge", "repos", "dev-docs"), { recursive: true });
|
|
1090
|
+
fs.writeFileSync(path.join(companyRoot, "knowledge", "overview.md"), "# kb\n");
|
|
1091
|
+
fs.writeFileSync(path.join(companyRoot, "knowledge", "repos", "dev-docs", "notes.md"), "discover knowledge\n");
|
|
1092
|
+
|
|
1093
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
1094
|
+
// Direct monkeypatch rather than vi.spyOn — vitest's console interception
|
|
1095
|
+
// can shadow a spy on console.warn, and we want to assert the REAL writes.
|
|
1096
|
+
const warnings: string[] = [];
|
|
1097
|
+
const originalWarn = console.warn;
|
|
1098
|
+
console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); };
|
|
1099
|
+
try {
|
|
1100
|
+
await share({
|
|
1101
|
+
paths: [companyRoot],
|
|
1102
|
+
company: "acme",
|
|
1103
|
+
vaultConfig: mockConfig,
|
|
1104
|
+
hqRoot: tmpDir,
|
|
1105
|
+
// no onEvent → defaultConsoleLogger
|
|
1106
|
+
});
|
|
1107
|
+
} finally {
|
|
1108
|
+
console.warn = originalWarn;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
expect(warnings.some((w) => w.includes("EXCLUDED from sync"))).toBe(true);
|
|
1112
|
+
expect(warnings.some((w) => w.includes("companies/acme/knowledge/repos"))).toBe(true);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
996
1115
|
it("scoped push defense-in-depth: a 403 on HEAD skips that key as scope-excluded without an error event", async () => {
|
|
997
1116
|
// Belt-and-suspenders: even if a key slips past the prefix filter (a grant
|
|
998
1117
|
// that changed mid-run, a pin outside the grant, prefix-coalesce
|
|
@@ -1374,6 +1493,7 @@ describe("share", () => {
|
|
|
1374
1493
|
e.type === "new-files" ||
|
|
1375
1494
|
e.type === "personal-vault-out-of-policy" ||
|
|
1376
1495
|
e.type === "scope-excluded" ||
|
|
1496
|
+
e.type === "ignore-excluded" ||
|
|
1377
1497
|
e.type === "delete-refused-bulk-asymmetry"
|
|
1378
1498
|
) return;
|
|
1379
1499
|
events.push({
|
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(
|
|
955
|
-
:
|
|
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
|
}
|
package/src/ignore.test.ts
CHANGED
|
@@ -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 {
|