@indigoai-us/hq-cli 5.111.2 → 5.113.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 (34) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/bin/hq-auth-refresh.d.ts +2 -0
  3. package/dist/bin/hq-auth-refresh.js +12 -6
  4. package/dist/command-catalog.generated.d.ts +6399 -0
  5. package/dist/command-catalog.generated.js +8275 -0
  6. package/dist/command-registration-plan.d.ts +394 -0
  7. package/dist/command-registration-plan.js +103 -0
  8. package/dist/commands/core.js +18 -0
  9. package/dist/commands/index-cmd.d.ts +2 -0
  10. package/dist/commands/index-cmd.js +15 -0
  11. package/dist/lazy-commands.d.ts +20 -44
  12. package/dist/lazy-commands.js +57 -58
  13. package/dist/lib/core-utils/qmd-reindex-after-sync.d.ts +1 -0
  14. package/dist/lib/core-utils/qmd-reindex-after-sync.js +12 -0
  15. package/dist/lib/core-utils/sentry-report.d.ts +76 -0
  16. package/dist/lib/core-utils/sentry-report.js +255 -0
  17. package/dist/lib/search-index/background.d.ts +2 -0
  18. package/dist/lib/search-index/background.js +28 -0
  19. package/dist/lib/search-index/max-doc-bytes.d.ts +220 -0
  20. package/dist/lib/search-index/max-doc-bytes.js +463 -0
  21. package/dist/main.d.ts +6 -0
  22. package/dist/main.js +87 -29
  23. package/dist/register-all.d.ts +4 -29
  24. package/dist/register-all.js +4 -235
  25. package/dist/sentry.d.ts +8 -2
  26. package/dist/sentry.js +17 -1
  27. package/dist/utils/cli-telemetry.d.ts +2 -1
  28. package/dist/utils/cli-telemetry.js +5 -5
  29. package/dist/utils/contribution-table.d.ts +1 -1
  30. package/dist/utils/version-check.d.ts +4 -1
  31. package/dist/utils/version-check.js +2 -2
  32. package/dist/utils/version-gate.d.ts +5 -1
  33. package/dist/utils/version-gate.js +4 -4
  34. package/package.json +3 -2
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Document size cap for the local HQ search index.
3
+ *
4
+ * WHY THIS EXISTS
5
+ *
6
+ * qmd indexes a collection by globbing its mask and reading every match — there
7
+ * is no size check anywhere on that path (`reindexCollection` in @tobilu/qmd
8
+ * 2.5.3 goes straight from the glob to `readFileSync`). HQ's project
9
+ * collections are registered with a `md`/`json` mask, which sweeps in build
10
+ * artifacts nobody would ever search as prose: browser trace dumps, perf
11
+ * captures, scraped datasets, lockfiles.
12
+ *
13
+ * Embedding is the consumer that makes this expensive. `qmd embed` runs under a
14
+ * hard 30-minute session cap; once the corpus stops fitting inside that budget
15
+ * the run aborts and discards its work, so an oversized corpus does not merely
16
+ * cost CPU — it stops the index ever converging.
17
+ *
18
+ * HOW IT WORKS
19
+ *
20
+ * qmd has no size option, but it does honour a per-collection `ignore` list of
21
+ * globs, applied at glob time, so an ignored file is never read, never chunked
22
+ * and never embedded. That list is not reachable through `qmd collection add`
23
+ * (there is no flag) and qmd's own YAML writer drops the key — but qmd's READER
24
+ * passes the parsed YAML collection object straight through to the store, and
25
+ * `syncConfigToDb` treats external config as authoritative ("External config
26
+ * always wins"). Writing an `ignore:` key into qmd's `index.yml` is therefore a
27
+ * durable way to express the cap, and it survives qmd's config resync.
28
+ *
29
+ * So: resolve the configured cap, walk each collection the way qmd would, and
30
+ * rewrite its `ignore` list to the files currently over the cap. The list is
31
+ * recomputed every run, so a file that shrinks or is deleted stops being
32
+ * ignored with no manual cleanup.
33
+ *
34
+ * OWNERSHIP
35
+ *
36
+ * The `ignore` list is shared with whatever an operator hand-wrote into
37
+ * `index.yml`. We therefore never treat "looks like our output" as proof of
38
+ * provenance: entries this module INTRODUCED are recorded in
39
+ * `<hqRoot>/.hq/index-size-ignores.json`, and only entries named in that record
40
+ * are removed on a later run. Anything else in `ignore` is left untouched.
41
+ *
42
+ * "Introduced" is doing real work in that sentence. An operator who already
43
+ * wrote `big.md` by hand keeps owning it even when our scan independently finds
44
+ * the same file oversized, so lowering the cap later can never delete their
45
+ * line. The record is also written BEFORE the config: an entry whose provenance
46
+ * failed to persist would read as foreign forever, which is to say permanently
47
+ * un-removable, and skipping the change is the cheaper mistake.
48
+ */
49
+ import * as fs from "node:fs";
50
+ /**
51
+ * Default cap: 100 KB. Chosen against a real HQ corpus where the median indexed
52
+ * document is ~1.2 KB — a 100 KB document is three orders of magnitude off the
53
+ * median and is, in practice, always generated output rather than prose.
54
+ */
55
+ export declare const DEFAULT_INDEX_MAX_DOC_BYTES: number;
56
+ /** Where this module records the ignore entries it generated. */
57
+ export declare function sizeIgnoreRecordPath(hqRoot: string): string;
58
+ /**
59
+ * qmd's config file, resolved the way qmd's own CLI resolves it.
60
+ *
61
+ * Mirrors `dist/cli/qmd.js`: with no `--index` flag, a project-local
62
+ * `.qmd/index.yaml` (then `.qmd/index.yml`), found by walking up from the
63
+ * directory qmd runs in, overrides the global config entirely. Otherwise the
64
+ * global ladder applies: `QMD_CONFIG_DIR`, then `XDG_CONFIG_HOME/qmd`, then
65
+ * `$HOME/.config/qmd`.
66
+ *
67
+ * Getting this wrong fails silently in the worst way: we would write a correct
68
+ * `ignore` list into a file qmd never reads, every test asserting the key would
69
+ * pass, and nothing would actually be skipped. A `$HOME`-only version of this
70
+ * function shipped to CI and did exactly that, because GitHub runners set
71
+ * `XDG_CONFIG_HOME` — the e2e positive control is what caught it.
72
+ *
73
+ * Returns undefined when no rung resolves, which callers treat as "no config to
74
+ * update" rather than inventing a path.
75
+ */
76
+ export declare function resolveQmdConfigPath(opts: {
77
+ env: NodeJS.ProcessEnv;
78
+ /** Directory qmd will run in; hq-cli always invokes it with cwd = hqRoot. */
79
+ startDir?: string;
80
+ existsFile?: (p: string) => boolean;
81
+ }): string | undefined;
82
+ /**
83
+ * Resolve the per-document size cap in bytes. Precedence, first match wins:
84
+ *
85
+ * 1. `HQ_INDEX_MAX_DOC_BYTES` env var (integer bytes),
86
+ * 2. `<hqRoot>/.hq/config.json` → `indexMaxDocBytes` (integer bytes),
87
+ * 3. `DEFAULT_INDEX_MAX_DOC_BYTES` (100 KB).
88
+ *
89
+ * `0` disables the cap — and is honoured rather than ignored, so turning the
90
+ * setting off also clears any ignore entries a previous run wrote.
91
+ *
92
+ * A negative, non-integer or otherwise unparseable override falls through to
93
+ * the next source rather than throwing: this is operator-facing config and a
94
+ * typo must not break an index run.
95
+ */
96
+ export declare function resolveIndexMaxDocBytes(opts?: {
97
+ hqRoot?: string;
98
+ /** Test seam — defaults to `process.env.HQ_INDEX_MAX_DOC_BYTES`. */
99
+ envValue?: string | undefined;
100
+ readFile?: (p: string) => string;
101
+ existsFile?: (p: string) => boolean;
102
+ }): number;
103
+ /**
104
+ * Extensions a qmd mask selects, lowercased and without the dot.
105
+ *
106
+ * HQ registers exactly two mask shapes today (see `deriveCollections`), but an
107
+ * operator's own collection may carry anything. An unrecognised mask returns
108
+ * `null`, meaning "match every file" — the cap then applies to the whole
109
+ * collection, which is the safe direction: we may ignore a file qmd would not
110
+ * have indexed (a no-op) rather than miss one it would.
111
+ */
112
+ export declare function maskExtensions(mask: string): Set<string> | null;
113
+ /** Injected filesystem seam, so the walk is testable without a real tree. */
114
+ export type OversizedScanIo = {
115
+ readdirSync: (directory: string) => fs.Dirent[];
116
+ statSync: (file: string) => {
117
+ size: number;
118
+ };
119
+ };
120
+ /**
121
+ * Escape a relative path so qmd's glob matcher treats it as a literal filename.
122
+ *
123
+ * Without this, a file genuinely named `reports/[final].json` becomes a
124
+ * character class: it fails to ignore itself (so the oversized document is
125
+ * still indexed) while matching unrelated paths like `reports/f.json` (so a
126
+ * small document silently disappears from search). Both halves of that are
127
+ * worse than not having the cap.
128
+ */
129
+ export declare function escapeGlobLiteral(relativePath: string): string;
130
+ /** Outcome of walking one collection for oversized files. */
131
+ export type OversizedScan = {
132
+ /** Escaped, sorted glob literals for files over the cap. */
133
+ oversized: string[];
134
+ /**
135
+ * Escaped literals for every file the walk POSITIVELY evaluated, over the cap
136
+ * or under it. An owned ignore entry missing from this set was not observed,
137
+ * which is only proof the file is gone when `complete` is true.
138
+ */
139
+ evaluated: Set<string>;
140
+ /** False when any directory or file could not be read, so the walk has gaps. */
141
+ complete: boolean;
142
+ };
143
+ /**
144
+ * Walk `collectionPath` for files the mask selects whose size exceeds `maxBytes`.
145
+ *
146
+ * Mirrors qmd's own walk: excluded directories are skipped, dot-prefixed files
147
+ * and directories are skipped at every level, and symlinks are never followed.
148
+ *
149
+ * `prunePrefixes` carries directory prefixes already excluded by the
150
+ * collection's own ignore globs. Descending into them would burn exactly the
151
+ * CPU this cap exists to save, and qmd is not going to read them anyway.
152
+ *
153
+ * A failure to read a directory or stat a file sets `complete = false` rather
154
+ * than being silently treated as "not oversized". The difference matters: an
155
+ * unreadable file that is still on disk must keep its existing ignore entry,
156
+ * because dropping it would let the next update read the very document the cap
157
+ * was protecting against.
158
+ */
159
+ export declare function findOversizedFiles(collectionPath: string, mask: string, maxBytes: number, io?: OversizedScanIo, prunePrefixes?: readonly string[]): OversizedScan;
160
+ /**
161
+ * Directory prefixes an operator's own ignore globs already exclude.
162
+ *
163
+ * Only the `dir/**` and `dir/**\/*` shapes are recognised. Any other glob stays
164
+ * unpruned, which costs a walk we did not need but never changes the result —
165
+ * the conservative direction for a heuristic whose only job is saving work.
166
+ */
167
+ export declare function prunablePrefixes(patterns: readonly string[]): string[];
168
+ /** What a single collection ended up ignoring on account of the size cap. */
169
+ export type CappedCollection = {
170
+ name: string;
171
+ ignored: string[];
172
+ };
173
+ export type IndexSizeLimitResult = {
174
+ /** The cap that was applied. `0` means the cap is disabled. */
175
+ maxBytes: number;
176
+ /** qmd's config file, or undefined when there was none to update. */
177
+ configPath?: string;
178
+ /** Per-collection oversized files, only for collections that have any. */
179
+ collections: CappedCollection[];
180
+ /** True when qmd's config was actually rewritten. */
181
+ changed: boolean;
182
+ };
183
+ export type ApplyIndexSizeLimitOptions = {
184
+ /** Test seam — defaults to `process.env`. */
185
+ env?: NodeJS.ProcessEnv;
186
+ maxBytes?: number;
187
+ scanIo?: OversizedScanIo;
188
+ readFile?: (p: string) => string;
189
+ writeFile?: (p: string, contents: string) => void;
190
+ existsFile?: (p: string) => boolean;
191
+ mkdir?: (p: string) => void;
192
+ };
193
+ /**
194
+ * Apply the size cap to every collection in qmd's config.
195
+ *
196
+ * Runs BEFORE `qmd update`, so the update that follows never reads an oversized
197
+ * file. Rewrites the config only when the resulting ignore lists differ from
198
+ * what is already on disk, so a steady-state HQ does no config churn.
199
+ *
200
+ * ## Concurrency
201
+ *
202
+ * qmd's config is shared with `qmd collection add` and with any other HQ
203
+ * process running an index pass. A naive read, long scan, then full-file dump
204
+ * would erase a collection registered while the scan was running.
205
+ *
206
+ * The order here is read, scan, RE-READ, then write. That re-read is the part
207
+ * that matters: the scan is slow (it walks every collection), so the config can
208
+ * easily move while it runs, and comparing the fresh bytes against the ones the
209
+ * merge was computed from catches exactly that. On a mismatch the whole merge is
210
+ * recomputed against the newer config, up to {@link CONFIG_WRITE_ATTEMPTS}
211
+ * times, after which we decline to write rather than guess. The write itself is
212
+ * a temp-file rename, so no reader ever sees a half-written config.
213
+ *
214
+ * This narrows the window to the microseconds between the re-read and the
215
+ * rename; it is not a mutex. A writer that lands inside that gap still loses.
216
+ * A lockfile would not close it either, because qmd's own writers would not
217
+ * take the lock, so the residual race is documented rather than papered over.
218
+ */
219
+ export declare function applyIndexSizeLimit(hqRoot: string, options?: ApplyIndexSizeLimitOptions): IndexSizeLimitResult;
220
+ //# sourceMappingURL=max-doc-bytes.d.ts.map
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Document size cap for the local HQ search index.
3
+ *
4
+ * WHY THIS EXISTS
5
+ *
6
+ * qmd indexes a collection by globbing its mask and reading every match — there
7
+ * is no size check anywhere on that path (`reindexCollection` in @tobilu/qmd
8
+ * 2.5.3 goes straight from the glob to `readFileSync`). HQ's project
9
+ * collections are registered with a `md`/`json` mask, which sweeps in build
10
+ * artifacts nobody would ever search as prose: browser trace dumps, perf
11
+ * captures, scraped datasets, lockfiles.
12
+ *
13
+ * Embedding is the consumer that makes this expensive. `qmd embed` runs under a
14
+ * hard 30-minute session cap; once the corpus stops fitting inside that budget
15
+ * the run aborts and discards its work, so an oversized corpus does not merely
16
+ * cost CPU — it stops the index ever converging.
17
+ *
18
+ * HOW IT WORKS
19
+ *
20
+ * qmd has no size option, but it does honour a per-collection `ignore` list of
21
+ * globs, applied at glob time, so an ignored file is never read, never chunked
22
+ * and never embedded. That list is not reachable through `qmd collection add`
23
+ * (there is no flag) and qmd's own YAML writer drops the key — but qmd's READER
24
+ * passes the parsed YAML collection object straight through to the store, and
25
+ * `syncConfigToDb` treats external config as authoritative ("External config
26
+ * always wins"). Writing an `ignore:` key into qmd's `index.yml` is therefore a
27
+ * durable way to express the cap, and it survives qmd's config resync.
28
+ *
29
+ * So: resolve the configured cap, walk each collection the way qmd would, and
30
+ * rewrite its `ignore` list to the files currently over the cap. The list is
31
+ * recomputed every run, so a file that shrinks or is deleted stops being
32
+ * ignored with no manual cleanup.
33
+ *
34
+ * OWNERSHIP
35
+ *
36
+ * The `ignore` list is shared with whatever an operator hand-wrote into
37
+ * `index.yml`. We therefore never treat "looks like our output" as proof of
38
+ * provenance: entries this module INTRODUCED are recorded in
39
+ * `<hqRoot>/.hq/index-size-ignores.json`, and only entries named in that record
40
+ * are removed on a later run. Anything else in `ignore` is left untouched.
41
+ *
42
+ * "Introduced" is doing real work in that sentence. An operator who already
43
+ * wrote `big.md` by hand keeps owning it even when our scan independently finds
44
+ * the same file oversized, so lowering the cap later can never delete their
45
+ * line. The record is also written BEFORE the config: an entry whose provenance
46
+ * failed to persist would read as foreign forever, which is to say permanently
47
+ * un-removable, and skipping the change is the cheaper mistake.
48
+ */
49
+ import * as fs from "node:fs";
50
+ import * as path from "node:path";
51
+ import YAML from "js-yaml";
52
+ /**
53
+ * Default cap: 100 KB. Chosen against a real HQ corpus where the median indexed
54
+ * document is ~1.2 KB — a 100 KB document is three orders of magnitude off the
55
+ * median and is, in practice, always generated output rather than prose.
56
+ */
57
+ export const DEFAULT_INDEX_MAX_DOC_BYTES = 100 * 1024;
58
+ /**
59
+ * Directories qmd's own reindex never descends into. Mirrored here so our scan
60
+ * sees exactly the files qmd would index — an ignore entry for a file qmd never
61
+ * reads is harmless, but it makes the generated list misleading to read.
62
+ */
63
+ const EXCLUDED_DIRECTORIES = new Set([
64
+ "node_modules",
65
+ ".git",
66
+ ".cache",
67
+ "vendor",
68
+ "dist",
69
+ "build",
70
+ ]);
71
+ /** Where this module records the ignore entries it generated. */
72
+ export function sizeIgnoreRecordPath(hqRoot) {
73
+ return path.join(hqRoot, ".hq", "index-size-ignores.json");
74
+ }
75
+ /** qmd's default index name; the config file and store are both named after it. */
76
+ const QMD_INDEX_NAME = "index";
77
+ /**
78
+ * qmd's config file, resolved the way qmd's own CLI resolves it.
79
+ *
80
+ * Mirrors `dist/cli/qmd.js`: with no `--index` flag, a project-local
81
+ * `.qmd/index.yaml` (then `.qmd/index.yml`), found by walking up from the
82
+ * directory qmd runs in, overrides the global config entirely. Otherwise the
83
+ * global ladder applies: `QMD_CONFIG_DIR`, then `XDG_CONFIG_HOME/qmd`, then
84
+ * `$HOME/.config/qmd`.
85
+ *
86
+ * Getting this wrong fails silently in the worst way: we would write a correct
87
+ * `ignore` list into a file qmd never reads, every test asserting the key would
88
+ * pass, and nothing would actually be skipped. A `$HOME`-only version of this
89
+ * function shipped to CI and did exactly that, because GitHub runners set
90
+ * `XDG_CONFIG_HOME` — the e2e positive control is what caught it.
91
+ *
92
+ * Returns undefined when no rung resolves, which callers treat as "no config to
93
+ * update" rather than inventing a path.
94
+ */
95
+ export function resolveQmdConfigPath(opts) {
96
+ const exists = opts.existsFile ?? fs.existsSync;
97
+ if (opts.startDir) {
98
+ const local = findLocalQmdConfig(path.resolve(opts.startDir), exists);
99
+ if (local)
100
+ return local;
101
+ }
102
+ const configDir = opts.env.QMD_CONFIG_DIR?.trim();
103
+ if (configDir)
104
+ return path.join(configDir, `${QMD_INDEX_NAME}.yml`);
105
+ const xdg = opts.env.XDG_CONFIG_HOME?.trim();
106
+ if (xdg)
107
+ return path.join(xdg, "qmd", `${QMD_INDEX_NAME}.yml`);
108
+ const home = opts.env.HOME?.trim();
109
+ if (home)
110
+ return path.join(home, ".config", "qmd", `${QMD_INDEX_NAME}.yml`);
111
+ return undefined;
112
+ }
113
+ /** Nearest `.qmd/index.{yaml,yml}` at or above `startDir`, matching qmd's walk. */
114
+ function findLocalQmdConfig(startDir, exists) {
115
+ let dir = startDir;
116
+ for (;;) {
117
+ for (const name of ["index.yaml", "index.yml"]) {
118
+ const candidate = path.join(dir, ".qmd", name);
119
+ if (exists(candidate))
120
+ return candidate;
121
+ }
122
+ const parent = path.dirname(dir);
123
+ if (parent === dir)
124
+ return undefined;
125
+ dir = parent;
126
+ }
127
+ }
128
+ /**
129
+ * Resolve the per-document size cap in bytes. Precedence, first match wins:
130
+ *
131
+ * 1. `HQ_INDEX_MAX_DOC_BYTES` env var (integer bytes),
132
+ * 2. `<hqRoot>/.hq/config.json` → `indexMaxDocBytes` (integer bytes),
133
+ * 3. `DEFAULT_INDEX_MAX_DOC_BYTES` (100 KB).
134
+ *
135
+ * `0` disables the cap — and is honoured rather than ignored, so turning the
136
+ * setting off also clears any ignore entries a previous run wrote.
137
+ *
138
+ * A negative, non-integer or otherwise unparseable override falls through to
139
+ * the next source rather than throwing: this is operator-facing config and a
140
+ * typo must not break an index run.
141
+ */
142
+ export function resolveIndexMaxDocBytes(opts = {}) {
143
+ const fromEnv = parseNonNegativeInt(opts.envValue !== undefined ? opts.envValue : process.env.HQ_INDEX_MAX_DOC_BYTES);
144
+ if (fromEnv !== null)
145
+ return fromEnv;
146
+ if (opts.hqRoot) {
147
+ const configPath = path.join(opts.hqRoot, ".hq", "config.json");
148
+ const exists = opts.existsFile ?? fs.existsSync;
149
+ const read = opts.readFile ?? ((p) => fs.readFileSync(p, "utf-8"));
150
+ if (exists(configPath)) {
151
+ try {
152
+ const cfg = JSON.parse(read(configPath));
153
+ const fromCfg = parseNonNegativeInt(cfg.indexMaxDocBytes);
154
+ if (fromCfg !== null)
155
+ return fromCfg;
156
+ }
157
+ catch {
158
+ // Malformed config → fall through to the default.
159
+ }
160
+ }
161
+ }
162
+ return DEFAULT_INDEX_MAX_DOC_BYTES;
163
+ }
164
+ /** A finite, non-negative integer parsed from a string/number, else null. */
165
+ function parseNonNegativeInt(value) {
166
+ if (typeof value === "number") {
167
+ return Number.isInteger(value) && value >= 0 ? value : null;
168
+ }
169
+ if (typeof value !== "string")
170
+ return null;
171
+ const trimmed = value.trim();
172
+ if (!/^\d+$/.test(trimmed))
173
+ return null;
174
+ const n = Number(trimmed);
175
+ return Number.isSafeInteger(n) ? n : null;
176
+ }
177
+ /**
178
+ * Extensions a qmd mask selects, lowercased and without the dot.
179
+ *
180
+ * HQ registers exactly two mask shapes today (see `deriveCollections`), but an
181
+ * operator's own collection may carry anything. An unrecognised mask returns
182
+ * `null`, meaning "match every file" — the cap then applies to the whole
183
+ * collection, which is the safe direction: we may ignore a file qmd would not
184
+ * have indexed (a no-op) rather than miss one it would.
185
+ */
186
+ export function maskExtensions(mask) {
187
+ const braced = /^\*\*\/\*\.\{([^}]+)\}$/.exec(mask);
188
+ if (braced) {
189
+ return new Set(braced[1].split(",").map((ext) => ext.trim().toLowerCase()).filter(Boolean));
190
+ }
191
+ const single = /^\*\*\/\*\.([A-Za-z0-9]+)$/.exec(mask);
192
+ if (single)
193
+ return new Set([single[1].toLowerCase()]);
194
+ return null;
195
+ }
196
+ const defaultScanIo = {
197
+ readdirSync: (directory) => fs.readdirSync(directory, { withFileTypes: true }),
198
+ statSync: (file) => fs.statSync(file),
199
+ };
200
+ /**
201
+ * Characters picomatch (via fast-glob, which is what qmd globs with) treats as
202
+ * pattern syntax rather than literal text. `^` and `$` are deliberately absent:
203
+ * they are regex syntax, not glob syntax, and escaping them would be a guess.
204
+ */
205
+ const GLOB_META = /[\\*?[\]{}()!+@|]/g;
206
+ /** Same set, without the `g` flag, so `.test()` carries no `lastIndex` state. */
207
+ const HAS_GLOB_META = /[\\*?[\]{}()!+@|]/;
208
+ /**
209
+ * Escape a relative path so qmd's glob matcher treats it as a literal filename.
210
+ *
211
+ * Without this, a file genuinely named `reports/[final].json` becomes a
212
+ * character class: it fails to ignore itself (so the oversized document is
213
+ * still indexed) while matching unrelated paths like `reports/f.json` (so a
214
+ * small document silently disappears from search). Both halves of that are
215
+ * worse than not having the cap.
216
+ */
217
+ export function escapeGlobLiteral(relativePath) {
218
+ return relativePath.replace(GLOB_META, (character) => `\\${character}`);
219
+ }
220
+ /**
221
+ * Walk `collectionPath` for files the mask selects whose size exceeds `maxBytes`.
222
+ *
223
+ * Mirrors qmd's own walk: excluded directories are skipped, dot-prefixed files
224
+ * and directories are skipped at every level, and symlinks are never followed.
225
+ *
226
+ * `prunePrefixes` carries directory prefixes already excluded by the
227
+ * collection's own ignore globs. Descending into them would burn exactly the
228
+ * CPU this cap exists to save, and qmd is not going to read them anyway.
229
+ *
230
+ * A failure to read a directory or stat a file sets `complete = false` rather
231
+ * than being silently treated as "not oversized". The difference matters: an
232
+ * unreadable file that is still on disk must keep its existing ignore entry,
233
+ * because dropping it would let the next update read the very document the cap
234
+ * was protecting against.
235
+ */
236
+ export function findOversizedFiles(collectionPath, mask, maxBytes, io = defaultScanIo, prunePrefixes = []) {
237
+ const evaluated = new Set();
238
+ if (maxBytes <= 0)
239
+ return { oversized: [], evaluated, complete: true };
240
+ const extensions = maskExtensions(mask);
241
+ const pruned = new Set(prunePrefixes);
242
+ const found = [];
243
+ let complete = true;
244
+ const walk = (directory, relative) => {
245
+ let entries;
246
+ try {
247
+ entries = io.readdirSync(directory);
248
+ }
249
+ catch {
250
+ complete = false; // A gap, not an absence.
251
+ return;
252
+ }
253
+ for (const entry of entries) {
254
+ if (entry.name.startsWith("."))
255
+ continue;
256
+ const childRelative = relative ? `${relative}/${entry.name}` : entry.name;
257
+ if (entry.isDirectory()) {
258
+ if (EXCLUDED_DIRECTORIES.has(entry.name))
259
+ continue;
260
+ if (pruned.has(childRelative))
261
+ continue;
262
+ walk(path.join(directory, entry.name), childRelative);
263
+ continue;
264
+ }
265
+ if (!entry.isFile())
266
+ continue;
267
+ if (extensions) {
268
+ const ext = path.extname(entry.name).slice(1).toLowerCase();
269
+ if (!extensions.has(ext))
270
+ continue;
271
+ }
272
+ const literal = escapeGlobLiteral(childRelative);
273
+ try {
274
+ const { size } = io.statSync(path.join(directory, entry.name));
275
+ evaluated.add(literal);
276
+ if (size > maxBytes)
277
+ found.push(literal);
278
+ }
279
+ catch {
280
+ complete = false; // Unreadable now; it may still be oversized.
281
+ }
282
+ }
283
+ };
284
+ walk(collectionPath, "");
285
+ return {
286
+ oversized: found.sort((left, right) => left.localeCompare(right)),
287
+ evaluated,
288
+ complete,
289
+ };
290
+ }
291
+ /**
292
+ * Directory prefixes an operator's own ignore globs already exclude.
293
+ *
294
+ * Only the `dir/**` and `dir/**\/*` shapes are recognised. Any other glob stays
295
+ * unpruned, which costs a walk we did not need but never changes the result —
296
+ * the conservative direction for a heuristic whose only job is saving work.
297
+ */
298
+ export function prunablePrefixes(patterns) {
299
+ const prefixes = [];
300
+ for (const pattern of patterns) {
301
+ const match = /^(.+?)\/\*\*(?:\/\*)?$/.exec(pattern);
302
+ if (match && !HAS_GLOB_META.test(match[1]))
303
+ prefixes.push(match[1]);
304
+ }
305
+ return prefixes;
306
+ }
307
+ /** How many times to re-read and re-merge when the config moves under us. */
308
+ const CONFIG_WRITE_ATTEMPTS = 3;
309
+ /**
310
+ * Apply the size cap to every collection in qmd's config.
311
+ *
312
+ * Runs BEFORE `qmd update`, so the update that follows never reads an oversized
313
+ * file. Rewrites the config only when the resulting ignore lists differ from
314
+ * what is already on disk, so a steady-state HQ does no config churn.
315
+ *
316
+ * ## Concurrency
317
+ *
318
+ * qmd's config is shared with `qmd collection add` and with any other HQ
319
+ * process running an index pass. A naive read, long scan, then full-file dump
320
+ * would erase a collection registered while the scan was running.
321
+ *
322
+ * The order here is read, scan, RE-READ, then write. That re-read is the part
323
+ * that matters: the scan is slow (it walks every collection), so the config can
324
+ * easily move while it runs, and comparing the fresh bytes against the ones the
325
+ * merge was computed from catches exactly that. On a mismatch the whole merge is
326
+ * recomputed against the newer config, up to {@link CONFIG_WRITE_ATTEMPTS}
327
+ * times, after which we decline to write rather than guess. The write itself is
328
+ * a temp-file rename, so no reader ever sees a half-written config.
329
+ *
330
+ * This narrows the window to the microseconds between the re-read and the
331
+ * rename; it is not a mutex. A writer that lands inside that gap still loses.
332
+ * A lockfile would not close it either, because qmd's own writers would not
333
+ * take the lock, so the residual race is documented rather than papered over.
334
+ */
335
+ export function applyIndexSizeLimit(hqRoot, options = {}) {
336
+ const env = options.env ?? process.env;
337
+ const maxBytes = options.maxBytes ?? resolveIndexMaxDocBytes({ hqRoot, envValue: env.HQ_INDEX_MAX_DOC_BYTES });
338
+ const exists = options.existsFile ?? fs.existsSync;
339
+ const read = options.readFile ?? ((p) => fs.readFileSync(p, "utf-8"));
340
+ const write = options.writeFile ?? atomicWrite;
341
+ const mkdir = options.mkdir ?? ((p) => fs.mkdirSync(p, { recursive: true }));
342
+ const configPath = resolveQmdConfigPath({ env, startDir: hqRoot, existsFile: exists });
343
+ if (!configPath)
344
+ return { maxBytes, collections: [], changed: false };
345
+ if (!exists(configPath))
346
+ return { maxBytes, configPath, collections: [], changed: false };
347
+ const previouslyOwned = readIgnoreRecord(hqRoot, { exists, read });
348
+ for (let attempt = 0; attempt < CONFIG_WRITE_ATTEMPTS; attempt++) {
349
+ let original;
350
+ let config;
351
+ try {
352
+ original = read(configPath);
353
+ config = YAML.load(original) ?? {};
354
+ }
355
+ catch {
356
+ // A config we cannot parse is a config we must not rewrite.
357
+ return { maxBytes, configPath, collections: [], changed: false };
358
+ }
359
+ const collections = config.collections;
360
+ if (!collections || typeof collections !== "object") {
361
+ return { maxBytes, configPath, collections: [], changed: false };
362
+ }
363
+ const nowOwned = {};
364
+ const capped = [];
365
+ for (const [name, entry] of Object.entries(collections)) {
366
+ if (!entry || typeof entry !== "object")
367
+ continue;
368
+ const collectionPath = typeof entry.path === "string" ? entry.path : undefined;
369
+ if (!collectionPath)
370
+ continue;
371
+ const mask = typeof entry.pattern === "string" ? entry.pattern : "**/*.md";
372
+ const existing = Array.isArray(entry.ignore)
373
+ ? entry.ignore.filter((value) => typeof value === "string")
374
+ : [];
375
+ // Provenance: an entry is ours only if a previous run recorded writing it.
376
+ const ours = new Set(previouslyOwned[name] ?? []);
377
+ const foreign = existing.filter((value) => !ours.has(value));
378
+ const foreignSet = new Set(foreign);
379
+ const scan = exists(collectionPath)
380
+ ? findOversizedFiles(collectionPath, mask, maxBytes, options.scanIo, prunablePrefixes(foreign))
381
+ : { oversized: [], evaluated: new Set(), complete: false };
382
+ // Keep an owned entry the scan could not positively re-evaluate. Dropping
383
+ // it on an incomplete walk would silently re-expose an oversized file.
384
+ const unverified = scan.complete
385
+ ? []
386
+ : [...ours].filter((value) => !scan.evaluated.has(value));
387
+ const mine = [...new Set([...scan.oversized, ...unverified])]
388
+ // Never claim an entry the operator already had: if we recorded it as
389
+ // ours, a later shrink would delete their line, not ours.
390
+ .filter((value) => !foreignSet.has(value))
391
+ .sort((left, right) => left.localeCompare(right));
392
+ const merged = [...new Set([...foreign, ...scan.oversized, ...unverified])].sort((left, right) => left.localeCompare(right));
393
+ if (merged.length > 0)
394
+ entry.ignore = merged;
395
+ else
396
+ delete entry.ignore;
397
+ if (mine.length > 0)
398
+ nowOwned[name] = mine;
399
+ if (scan.oversized.length > 0)
400
+ capped.push({ name, ignored: scan.oversized });
401
+ }
402
+ const updated = YAML.dump(config, { lineWidth: 0 });
403
+ if (updated === original)
404
+ return { maxBytes, configPath, collections: capped, changed: false };
405
+ // Provenance first. A config mutation whose record is lost leaves entries
406
+ // that look foreign forever, so neither a shrinking file nor `0` can clear
407
+ // them. Better to skip the mutation than to strand it.
408
+ if (!writeIgnoreRecord(hqRoot, nowOwned, { mkdir, write })) {
409
+ return { maxBytes, configPath, collections: capped, changed: false };
410
+ }
411
+ // Re-read immediately before writing: if another writer landed since the
412
+ // read above, recompute against their version rather than erasing it.
413
+ let current;
414
+ try {
415
+ current = read(configPath);
416
+ }
417
+ catch {
418
+ return { maxBytes, configPath, collections: capped, changed: false };
419
+ }
420
+ if (current !== original)
421
+ continue;
422
+ write(configPath, updated);
423
+ return { maxBytes, configPath, collections: capped, changed: true };
424
+ }
425
+ return { maxBytes, configPath, collections: [], changed: false };
426
+ }
427
+ /** Write via temp file + rename, so a concurrent reader never sees a partial config. */
428
+ function atomicWrite(target, contents) {
429
+ const temporary = `${target}.hq-${process.pid}.tmp`;
430
+ fs.writeFileSync(temporary, contents, "utf-8");
431
+ fs.renameSync(temporary, target);
432
+ }
433
+ function readIgnoreRecord(hqRoot, io) {
434
+ const recordPath = sizeIgnoreRecordPath(hqRoot);
435
+ if (!io.exists(recordPath))
436
+ return {};
437
+ try {
438
+ const parsed = JSON.parse(io.read(recordPath));
439
+ const result = {};
440
+ for (const [name, value] of Object.entries(parsed)) {
441
+ if (Array.isArray(value)) {
442
+ result[name] = value.filter((v) => typeof v === "string");
443
+ }
444
+ }
445
+ return result;
446
+ }
447
+ catch {
448
+ return {}; // A corrupt record means we claim nothing, and clobber nothing.
449
+ }
450
+ }
451
+ /** True when the record landed; false tells the caller not to mutate the config. */
452
+ function writeIgnoreRecord(hqRoot, owned, io) {
453
+ const recordPath = sizeIgnoreRecordPath(hqRoot);
454
+ try {
455
+ io.mkdir(path.dirname(recordPath));
456
+ io.write(recordPath, `${JSON.stringify(owned, null, 2)}\n`);
457
+ return true;
458
+ }
459
+ catch {
460
+ return false;
461
+ }
462
+ }
463
+ //# sourceMappingURL=max-doc-bytes.js.map