@indigoai-us/hq-cli 5.112.0 → 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.
- package/CHANGELOG.md +12 -0
- package/dist/commands/index-cmd.d.ts +2 -0
- package/dist/commands/index-cmd.js +15 -0
- package/dist/lib/core-utils/qmd-reindex-after-sync.d.ts +1 -0
- package/dist/lib/core-utils/qmd-reindex-after-sync.js +12 -0
- package/dist/lib/search-index/background.d.ts +2 -0
- package/dist/lib/search-index/background.js +28 -0
- package/dist/lib/search-index/max-doc-bytes.d.ts +220 -0
- package/dist/lib/search-index/max-doc-bytes.js +463 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.113.0] — 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Search indexing now skips any file larger than 100 KB. HQ indexes every `.md`
|
|
10
|
+
and `.json` file under your project folders, which pulled in things nobody
|
|
11
|
+
searches as prose: browser trace dumps, perf captures, scraped datasets,
|
|
12
|
+
lockfiles. A few of those were large enough that indexing ran out of time
|
|
13
|
+
before it finished, so the search index never caught up. Set a different limit
|
|
14
|
+
with `HQ_INDEX_MAX_DOC_BYTES`, or `indexMaxDocBytes` in your HQ root's
|
|
15
|
+
`.hq/config.json`. Set it to `0` to index everything again.
|
|
16
|
+
|
|
5
17
|
## [5.112.0] — 2026-09-15
|
|
6
18
|
|
|
7
19
|
### Added
|
|
@@ -3,6 +3,8 @@ import { type RunQmdOptions, type SearchCollection, type QmdProcessResult } from
|
|
|
3
3
|
import { type BackgroundDependencies, type BackgroundResult, type BackgroundStatus } from '../lib/search-index/background.js';
|
|
4
4
|
export type SearchIndexDependencies = {
|
|
5
5
|
reconcileCollections: (hqRoot: string) => unknown;
|
|
6
|
+
/** Apply the per-document size cap to qmd's config before the update reads anything. */
|
|
7
|
+
applyIndexSizeLimit: (hqRoot: string) => unknown;
|
|
6
8
|
deriveCollections: (hqRoot: string) => SearchCollection[];
|
|
7
9
|
listRegisteredCollections: (hqRoot: string, options?: RunQmdOptions) => Set<string>;
|
|
8
10
|
resolveQmdBin: () => string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Option } from 'commander';
|
|
2
2
|
import { deriveCollections, listRegisteredCollections, packageLocalBin, reconcileCollections, resolveQmdBin, resolveQmdVersion, runQmd, } from '../lib/search-index/index.js';
|
|
3
3
|
import { backgroundStatus, defaultBackgroundDependencies, runBackgroundLauncher, runBackgroundWorker, } from '../lib/search-index/background.js';
|
|
4
|
+
import { applyIndexSizeLimit } from '../lib/search-index/max-doc-bytes.js';
|
|
4
5
|
import { findHqRoot } from '../utils/manifest.js';
|
|
5
6
|
import { QMD_NATIVE_BINDING_REMEDY, isQmdNativeBindingError, } from '../utils/qmd-native-binding-error.js';
|
|
6
7
|
import { isQmdStoreMissingError, qmdStoreMissingMessage, } from '../utils/qmd-store-missing-error.js';
|
|
@@ -8,6 +9,7 @@ import { isQmdStoreUnopenableError, qmdStoreUnopenableMessage, } from '../utils/
|
|
|
8
9
|
import { isQmdWorkdirMissingError, qmdWorkdirMissingMessage, } from '../utils/qmd-workdir-missing-error.js';
|
|
9
10
|
const defaults = {
|
|
10
11
|
reconcileCollections,
|
|
12
|
+
applyIndexSizeLimit,
|
|
11
13
|
deriveCollections,
|
|
12
14
|
listRegisteredCollections,
|
|
13
15
|
resolveQmdBin,
|
|
@@ -20,6 +22,18 @@ const defaults = {
|
|
|
20
22
|
/** Incrementally update qmd, embedding only when an operator explicitly asks. */
|
|
21
23
|
export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
|
|
22
24
|
dependencies.reconcileCollections(hqRoot);
|
|
25
|
+
// Must precede the update: an oversized file is skipped at glob time or not
|
|
26
|
+
// at all, so capping after the update would still have paid to read it.
|
|
27
|
+
//
|
|
28
|
+
// Best-effort, matching the background worker. The cap writes to qmd's config
|
|
29
|
+
// and to `.hq/`, either of which can be read-only on a locked-down box. An
|
|
30
|
+
// index that is merely uncapped is far better than no `qmd update` at all.
|
|
31
|
+
try {
|
|
32
|
+
dependencies.applyIndexSizeLimit(hqRoot);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* capping is an optimisation; indexing is the job */
|
|
36
|
+
}
|
|
23
37
|
dependencies.runQmd(['update'], { cwd: hqRoot });
|
|
24
38
|
if (embed)
|
|
25
39
|
dependencies.runQmd(['embed'], { cwd: hqRoot });
|
|
@@ -32,6 +46,7 @@ function makeBackgroundDependencies(hqRoot, dependencies) {
|
|
|
32
46
|
...defaultBackgroundDependencies(hqRoot),
|
|
33
47
|
resolveQmdBin: dependencies.resolveQmdBin,
|
|
34
48
|
reconcileCollections: dependencies.reconcileCollections,
|
|
49
|
+
applyIndexSizeLimit: dependencies.applyIndexSizeLimit,
|
|
35
50
|
runQmd: dependencies.runQmd,
|
|
36
51
|
};
|
|
37
52
|
}
|
|
@@ -10,6 +10,7 @@ export type QmdReindexOptions = UtilityIo & {
|
|
|
10
10
|
bin: string;
|
|
11
11
|
cwd: string;
|
|
12
12
|
}) => unknown;
|
|
13
|
+
sizeLimit?: (hqRoot: string) => unknown;
|
|
13
14
|
};
|
|
14
15
|
export declare function qmdReindexAfterSync(args?: string[], options?: QmdReindexOptions): number;
|
|
15
16
|
//# sourceMappingURL=qmd-reindex-after-sync.d.ts.map
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { reconcileCollections, resolveQmdBin, runQmd } from "../search-index/index.js";
|
|
15
|
+
import { applyIndexSizeLimit } from "../search-index/max-doc-bytes.js";
|
|
15
16
|
export function qmdReindexAfterSync(args = [], options = {}) {
|
|
16
17
|
let hqRoot = "";
|
|
17
18
|
let embed = false;
|
|
@@ -36,6 +37,7 @@ export function qmdReindexAfterSync(args = [], options = {}) {
|
|
|
36
37
|
return 0;
|
|
37
38
|
const reconcile = options.reconcile ?? ((root, opts) => reconcileCollections(root, opts));
|
|
38
39
|
const run = options.run ?? ((argv, opts) => runQmd(argv, opts));
|
|
40
|
+
const sizeLimit = options.sizeLimit ?? ((root) => applyIndexSizeLimit(root));
|
|
39
41
|
// Every qmd interaction is best-effort: the shell suffixed each with `|| true`.
|
|
40
42
|
try {
|
|
41
43
|
reconcile(hqRoot, { bin });
|
|
@@ -43,6 +45,16 @@ export function qmdReindexAfterSync(args = [], options = {}) {
|
|
|
43
45
|
catch {
|
|
44
46
|
/* registration is advisory; an update still helps */
|
|
45
47
|
}
|
|
48
|
+
// The cap has to land before the update, not after: qmd decides what to read
|
|
49
|
+
// at glob time, so an ignore entry written afterwards saves nothing on this
|
|
50
|
+
// pass. This is the post-sync entry point, which reaches `qmd update` without
|
|
51
|
+
// going through `hq index sync` at all.
|
|
52
|
+
try {
|
|
53
|
+
sizeLimit(hqRoot);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* an uncapped index is worse than none, but still better than a failed sync */
|
|
57
|
+
}
|
|
46
58
|
try {
|
|
47
59
|
run(["update"], { bin, cwd: hqRoot });
|
|
48
60
|
}
|
|
@@ -15,6 +15,8 @@ export type BackgroundDependencies = {
|
|
|
15
15
|
isProcessAlive: (pid: number) => boolean;
|
|
16
16
|
resolveQmdBin: () => string;
|
|
17
17
|
reconcileCollections: (hqRoot: string) => unknown;
|
|
18
|
+
/** Apply the per-document size cap to qmd's config before the update reads anything. */
|
|
19
|
+
applyIndexSizeLimit: (hqRoot: string) => unknown;
|
|
18
20
|
runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
|
|
19
21
|
spawnWorker: (options: {
|
|
20
22
|
logPath: string;
|
|
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { Sentry } from '../../sentry.js';
|
|
5
5
|
import { reconcileCollections as defaultReconcileCollections, resolveQmdBin as defaultResolveQmdBin, runQmd as defaultRunQmd, } from './index.js';
|
|
6
|
+
import { applyIndexSizeLimit as defaultApplyIndexSizeLimit } from './max-doc-bytes.js';
|
|
6
7
|
const LOCK_NAME = 'qmd-reindex-bg.lock';
|
|
7
8
|
const COMPLETE_NAME = 'qmd-reindex-bg.completed';
|
|
8
9
|
function errnoInfo(error) {
|
|
@@ -119,6 +120,7 @@ export function defaultBackgroundDependencies(hqRoot) {
|
|
|
119
120
|
isProcessAlive: alive,
|
|
120
121
|
resolveQmdBin: defaultResolveQmdBin,
|
|
121
122
|
reconcileCollections: defaultReconcileCollections,
|
|
123
|
+
applyIndexSizeLimit: defaultApplyIndexSizeLimit,
|
|
122
124
|
runQmd: defaultRunQmd,
|
|
123
125
|
spawnWorker: defaultSpawnWorker,
|
|
124
126
|
};
|
|
@@ -420,6 +422,21 @@ function capWorkerLog(logPath, env) {
|
|
|
420
422
|
function stepOutput(result) {
|
|
421
423
|
return `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
422
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* One worker-log line summarising the size cap, so an operator reading the log
|
|
427
|
+
* can see WHY a file stopped being indexed. Shaped defensively because the
|
|
428
|
+
* dependency is typed `unknown` at the seam.
|
|
429
|
+
*/
|
|
430
|
+
function sizeLimitOutput(result) {
|
|
431
|
+
const r = result;
|
|
432
|
+
if (typeof r?.maxBytes !== 'number')
|
|
433
|
+
return '';
|
|
434
|
+
if (r.maxBytes === 0)
|
|
435
|
+
return '[qmd-reindex-bg] size cap disabled\n';
|
|
436
|
+
const collections = r.collections ?? [];
|
|
437
|
+
const files = collections.reduce((total, collection) => total + collection.ignored.length, 0);
|
|
438
|
+
return `[qmd-reindex-bg] size cap ${r.maxBytes}B — ${files} file(s) skipped across ${collections.length} collection(s)\n`;
|
|
439
|
+
}
|
|
423
440
|
function errorOutput(error) {
|
|
424
441
|
const e = error;
|
|
425
442
|
return `${e.stdout ?? ''}${e.stderr ?? ''}` || (e.message ?? '');
|
|
@@ -500,6 +517,17 @@ export async function runBackgroundWorker(dependencies) {
|
|
|
500
517
|
// The shell worker has no collection-registration step. Keep this #306
|
|
501
518
|
// integration best-effort so it cannot suppress a later index update.
|
|
502
519
|
}
|
|
520
|
+
// The size cap rewrites qmd's ignore lists, so it MUST land before the
|
|
521
|
+
// update below — an oversized file is skipped at glob time or not at all.
|
|
522
|
+
// Best-effort for the same reason as reconciliation: a capping failure must
|
|
523
|
+
// not suppress an otherwise healthy index update.
|
|
524
|
+
try {
|
|
525
|
+
const capped = dependencies.applyIndexSizeLimit(dependencies.hqRoot);
|
|
526
|
+
appendWorkerLog(logPath, sizeLimitOutput(capped));
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
appendWorkerLog(logPath, errorOutput(error));
|
|
530
|
+
}
|
|
503
531
|
if (await signalWindow())
|
|
504
532
|
return { state: 'terminated' };
|
|
505
533
|
try {
|
|
@@ -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
|