@smeltjs/core 0.4.0 → 0.6.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/dist/apply.d.ts.map +1 -1
- package/dist/apply.js +8 -2
- package/dist/apply.js.map +1 -1
- package/dist/cli/bin.d.ts.map +1 -1
- package/dist/cli/bin.js +9 -4
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/doctor.d.ts +66 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +137 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/hooks.d.ts +36 -2
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +108 -64
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +12 -16
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/installed.d.ts +56 -0
- package/dist/cli/installed.d.ts.map +1 -0
- package/dist/cli/installed.js +105 -0
- package/dist/cli/installed.js.map +1 -0
- package/dist/cli/lava.d.ts +31 -0
- package/dist/cli/lava.d.ts.map +1 -0
- package/dist/cli/lava.js +82 -0
- package/dist/cli/lava.js.map +1 -0
- package/dist/cli/report.d.ts +10 -1
- package/dist/cli/report.d.ts.map +1 -1
- package/dist/cli/report.js +16 -1
- package/dist/cli/report.js.map +1 -1
- package/dist/cli/setup.d.ts +87 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +513 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/shell.d.ts +11 -4
- package/dist/cli/shell.d.ts.map +1 -1
- package/dist/cli/shell.js.map +1 -1
- package/dist/cli/subcommands/agents.d.ts.map +1 -1
- package/dist/cli/subcommands/agents.js +2 -1
- package/dist/cli/subcommands/agents.js.map +1 -1
- package/dist/cli/subcommands/doctor.d.ts +15 -0
- package/dist/cli/subcommands/doctor.d.ts.map +1 -0
- package/dist/cli/subcommands/doctor.js +43 -0
- package/dist/cli/subcommands/doctor.js.map +1 -0
- package/dist/cli/subcommands/flags.d.ts +16 -0
- package/dist/cli/subcommands/flags.d.ts.map +1 -1
- package/dist/cli/subcommands/flags.js +43 -4
- package/dist/cli/subcommands/flags.js.map +1 -1
- package/dist/cli/subcommands/hooks.d.ts.map +1 -1
- package/dist/cli/subcommands/hooks.js +10 -2
- package/dist/cli/subcommands/hooks.js.map +1 -1
- package/dist/cli/subcommands/init.d.ts.map +1 -1
- package/dist/cli/subcommands/init.js +2 -1
- package/dist/cli/subcommands/init.js.map +1 -1
- package/dist/cli/subcommands/registry.d.ts +3 -1
- package/dist/cli/subcommands/registry.d.ts.map +1 -1
- package/dist/cli/subcommands/registry.js +6 -0
- package/dist/cli/subcommands/registry.js.map +1 -1
- package/dist/cli/subcommands/setup.d.ts +23 -0
- package/dist/cli/subcommands/setup.d.ts.map +1 -0
- package/dist/cli/subcommands/setup.js +87 -0
- package/dist/cli/subcommands/setup.js.map +1 -0
- package/dist/cli/subcommands/smelt.d.ts +3 -0
- package/dist/cli/subcommands/smelt.d.ts.map +1 -1
- package/dist/cli/subcommands/smelt.js +5 -1
- package/dist/cli/subcommands/smelt.js.map +1 -1
- package/dist/cli/subcommands/stats.d.ts +8 -3
- package/dist/cli/subcommands/stats.d.ts.map +1 -1
- package/dist/cli/subcommands/stats.js +15 -5
- package/dist/cli/subcommands/stats.js.map +1 -1
- package/dist/cli/subcommands/subcommand.d.ts +1 -1
- package/dist/cli/subcommands/subcommand.d.ts.map +1 -1
- package/dist/cli/subcommands/subcommand.js.map +1 -1
- package/dist/cli/wizard.d.ts +72 -0
- package/dist/cli/wizard.d.ts.map +1 -0
- package/dist/cli/wizard.js +86 -0
- package/dist/cli/wizard.js.map +1 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -1
- package/dist/harness/claude-code.d.ts.map +1 -1
- package/dist/harness/claude-code.js +10 -0
- package/dist/harness/claude-code.js.map +1 -1
- package/dist/harness/codex.d.ts.map +1 -1
- package/dist/harness/codex.js +1 -0
- package/dist/harness/codex.js.map +1 -1
- package/dist/harness/grok.d.ts.map +1 -1
- package/dist/harness/grok.js +4 -1
- package/dist/harness/grok.js.map +1 -1
- package/dist/harness/kilocode.d.ts.map +1 -1
- package/dist/harness/kilocode.js +1 -1
- package/dist/harness/kilocode.js.map +1 -1
- package/dist/harness/opencode.d.ts.map +1 -1
- package/dist/harness/opencode.js +10 -0
- package/dist/harness/opencode.js.map +1 -1
- package/dist/harness/profile.d.ts +23 -2
- package/dist/harness/profile.d.ts.map +1 -1
- package/dist/harness/profile.js.map +1 -1
- package/dist/harness/snippet.d.ts +14 -1
- package/dist/harness/snippet.d.ts.map +1 -1
- package/dist/harness/snippet.js +18 -2
- package/dist/harness/snippet.js.map +1 -1
- package/dist/hooks/focus-terms.d.ts +57 -0
- package/dist/hooks/focus-terms.d.ts.map +1 -0
- package/dist/hooks/focus-terms.js +230 -0
- package/dist/hooks/focus-terms.js.map +1 -0
- package/dist/hooks/guard-core.d.ts +8 -16
- package/dist/hooks/guard-core.d.ts.map +1 -1
- package/dist/hooks/guard-core.js +22 -120
- package/dist/hooks/guard-core.js.map +1 -1
- package/dist/index.d.ts +16 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -3
- package/dist/index.js.map +1 -1
- package/dist/ops/index.d.ts +3 -3
- package/dist/ops/index.d.ts.map +1 -1
- package/dist/ops/index.js +2 -2
- package/dist/ops/index.js.map +1 -1
- package/dist/ops/verbs.d.ts +57 -1
- package/dist/ops/verbs.d.ts.map +1 -1
- package/dist/ops/verbs.js +49 -1
- package/dist/ops/verbs.js.map +1 -1
- package/dist/plan/auto.d.ts +9 -2
- package/dist/plan/auto.d.ts.map +1 -1
- package/dist/plan/auto.js +14 -2
- package/dist/plan/auto.js.map +1 -1
- package/dist/plan/diff.d.ts +45 -0
- package/dist/plan/diff.d.ts.map +1 -0
- package/dist/plan/diff.js +284 -0
- package/dist/plan/diff.js.map +1 -0
- package/dist/plan/json.d.ts +37 -0
- package/dist/plan/json.d.ts.map +1 -0
- package/dist/plan/json.js +181 -0
- package/dist/plan/json.js.map +1 -0
- package/dist/plan/kind.d.ts +28 -0
- package/dist/plan/kind.d.ts.map +1 -0
- package/dist/plan/kind.js +57 -0
- package/dist/plan/kind.js.map +1 -0
- package/dist/plan/offsets.d.ts +8 -0
- package/dist/plan/offsets.d.ts.map +1 -0
- package/dist/plan/offsets.js +19 -0
- package/dist/plan/offsets.js.map +1 -0
- package/dist/plan/planners.d.ts +15 -5
- package/dist/plan/planners.d.ts.map +1 -1
- package/dist/plan/planners.js +13 -5
- package/dist/plan/planners.js.map +1 -1
- package/dist/plan/structural.d.ts.map +1 -1
- package/dist/plan/structural.js +106 -14
- package/dist/plan/structural.js.map +1 -1
- package/dist/retrieve.d.ts +22 -1
- package/dist/retrieve.d.ts.map +1 -1
- package/dist/retrieve.js +59 -0
- package/dist/retrieve.js.map +1 -1
- package/dist/setup/recipe.d.ts +73 -0
- package/dist/setup/recipe.d.ts.map +1 -0
- package/dist/setup/recipe.js +76 -0
- package/dist/setup/recipe.js.map +1 -0
- package/dist/smelter.d.ts +4 -0
- package/dist/smelter.d.ts.map +1 -1
- package/dist/smelter.js +4 -0
- package/dist/smelter.js.map +1 -1
- package/dist/stats.d.ts +16 -1
- package/dist/stats.d.ts.map +1 -1
- package/dist/stats.js +29 -0
- package/dist/stats.js.map +1 -1
- package/dist/store-dir.d.ts +9 -2
- package/dist/store-dir.d.ts.map +1 -1
- package/dist/store-dir.js +48 -7
- package/dist/store-dir.js.map +1 -1
- package/dist/store.d.ts +4 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +12 -2
- package/dist/store.js.map +1 -1
- package/dist/text/json-edit.d.ts +15 -1
- package/dist/text/json-edit.d.ts.map +1 -1
- package/dist/text/json-edit.js +76 -5
- package/dist/text/json-edit.js.map +1 -1
- package/dist/types.d.ts +116 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/store-dir.js
CHANGED
|
@@ -4,7 +4,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
import { HashCollisionError, SmeltError, StoreCorruptionError, StoreFormatError, UnknownHashError, } from './errors.js';
|
|
6
6
|
import { contentHash } from './hash.js';
|
|
7
|
-
import { retrieveStats } from './stats.js';
|
|
7
|
+
import { retrieveStats, ruleLedger } from './stats.js';
|
|
8
8
|
/**
|
|
9
9
|
* The format marker every store directory carries, and the one version this code
|
|
10
10
|
* understands. A future layout is a new version, refused loudly by old code — never a
|
|
@@ -18,8 +18,16 @@ export const DIRECTORY_STORE_VERSION = 1;
|
|
|
18
18
|
* key can never traverse out of `blobs/` or collide with `format.json`.
|
|
19
19
|
*/
|
|
20
20
|
const KEY_PATTERN = /^[0-9a-f]{4,128}$/;
|
|
21
|
-
/** One
|
|
21
|
+
/** One counter line: a kind, a space, and the hash as a JSON string literal. */
|
|
22
22
|
const LOG_LINE = /^(hit|miss|corrupt) ("(?:[^"\\]|\\.)*")$/;
|
|
23
|
+
/**
|
|
24
|
+
* One ledger line: `put`, the hash, and the rule id — both JSON string literals. A
|
|
25
|
+
* separate pattern from {@link LOG_LINE} on purpose: the counter fold matches only
|
|
26
|
+
* counter lines and skips these, exactly as a reader that predates the ledger skips a
|
|
27
|
+
* line it does not know, so a directory written by this version reads as the same
|
|
28
|
+
* counters under the previous one (`test/ledger.test.ts` pins that).
|
|
29
|
+
*/
|
|
30
|
+
const PUT_LINE = /^put ("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")$/;
|
|
23
31
|
/**
|
|
24
32
|
* A persistent {@link ElisionStore} over a content-addressed directory. `node:fs` only —
|
|
25
33
|
* no SQLite, no new dependency, nothing that phones home. Elisions put here outlive the
|
|
@@ -33,6 +41,7 @@ const LOG_LINE = /^(hit|miss|corrupt) ("(?:[^"\\]|\\.)*")$/;
|
|
|
33
41
|
* blobs/<hash> one file per elision: the exact UTF-8 bytes, named by their content hash
|
|
34
42
|
* tmp/ staging for atomic writes; never read, safe to sweep
|
|
35
43
|
* retrievals.log append-only journal: `hit "<hash>"` | `miss "<hash>"` | `corrupt "<hash>"`
|
|
44
|
+
* | `put "<hash>" "<rule>"` (the ledger: which rule cut what)
|
|
36
45
|
* ```
|
|
37
46
|
*
|
|
38
47
|
* **Nothing lives in memory.** Every read — `stats()` included — comes off the disk, so
|
|
@@ -129,7 +138,16 @@ export class DirectoryElisionStore {
|
|
|
129
138
|
mkdirSync(this.#tmpDir, { recursive: true });
|
|
130
139
|
this.#claimFormat(markerPath);
|
|
131
140
|
}
|
|
132
|
-
put(content) {
|
|
141
|
+
put(content, reason) {
|
|
142
|
+
const hash = this.#putBlob(content);
|
|
143
|
+
// The ledger line, after the bytes are safe: attribution is bookkeeping, and a
|
|
144
|
+
// failure to write it is surfaced as a warning rather than a failed put.
|
|
145
|
+
if (reason !== undefined)
|
|
146
|
+
this.#appendLogCounting('put', hash, reason.rule);
|
|
147
|
+
return hash;
|
|
148
|
+
}
|
|
149
|
+
/** The publish itself: verify or write the blob, return its hash. */
|
|
150
|
+
#putBlob(content) {
|
|
133
151
|
const hash = this.#hash(content);
|
|
134
152
|
if (!KEY_PATTERN.test(hash)) {
|
|
135
153
|
throw new SmeltError(`smelt: hash "${hash}" is not usable as a storage key — it must match ` +
|
|
@@ -245,6 +263,26 @@ export class DirectoryElisionStore {
|
|
|
245
263
|
stats() {
|
|
246
264
|
return retrieveStats(this.rawCounters());
|
|
247
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* The per-rule ledger: a fold over the journal's `put` lines against its `hit`
|
|
268
|
+
* lines, derived by the shared `ruleLedger()`. Uncounted, and read off the disk
|
|
269
|
+
* like everything else here, so two processes agree.
|
|
270
|
+
*/
|
|
271
|
+
ledger() {
|
|
272
|
+
const puts = [];
|
|
273
|
+
const hits = new Set();
|
|
274
|
+
for (const line of this.#readLog().split('\n')) {
|
|
275
|
+
const put = PUT_LINE.exec(line);
|
|
276
|
+
if (put !== null) {
|
|
277
|
+
puts.push({ hash: JSON.parse(put[1]), rule: JSON.parse(put[2]) });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const counter = LOG_LINE.exec(line);
|
|
281
|
+
if (counter !== null && counter[1] === 'hit')
|
|
282
|
+
hits.add(JSON.parse(counter[2]));
|
|
283
|
+
}
|
|
284
|
+
return ruleLedger(puts, hits);
|
|
285
|
+
}
|
|
248
286
|
/** The blob's exact content, or `undefined` when no such blob is stored. */
|
|
249
287
|
#readBlob(hash) {
|
|
250
288
|
if (!KEY_PATTERN.test(hash))
|
|
@@ -283,9 +321,9 @@ export class DirectoryElisionStore {
|
|
|
283
321
|
* so it is caught and surfaced as a distinct `process.emitWarning` — see the class
|
|
284
322
|
* doc, and the read-only-journal case in `test/store-dir.test.ts`.
|
|
285
323
|
*/
|
|
286
|
-
#appendLogCounting(kind, hash) {
|
|
324
|
+
#appendLogCounting(kind, hash, rule) {
|
|
287
325
|
try {
|
|
288
|
-
this.#appendLog(kind, hash);
|
|
326
|
+
this.#appendLog(kind, hash, rule);
|
|
289
327
|
}
|
|
290
328
|
catch (error) {
|
|
291
329
|
process.emitWarning(`smelt: could not journal a "${kind}" for hash "${hash}" in ${this.#logPath} ` +
|
|
@@ -301,10 +339,13 @@ export class DirectoryElisionStore {
|
|
|
301
339
|
* partial record with no trailing newline — can never bleed into this one: the tear
|
|
302
340
|
* stays on its own line and is skipped by `stats()`, as blank lines are.
|
|
303
341
|
*/
|
|
304
|
-
#appendLog(kind, hash) {
|
|
342
|
+
#appendLog(kind, hash, rule) {
|
|
305
343
|
const fd = openSync(this.#logPath, 'a');
|
|
306
344
|
try {
|
|
307
|
-
const
|
|
345
|
+
const fields = rule === undefined
|
|
346
|
+
? [kind, JSON.stringify(hash)]
|
|
347
|
+
: [kind, JSON.stringify(hash), JSON.stringify(rule)];
|
|
348
|
+
const record = Buffer.from(`\n${fields.join(' ')}\n`, 'utf8');
|
|
308
349
|
let written = 0;
|
|
309
350
|
while (written < record.length) {
|
|
310
351
|
written += writeSync(fd, record, written);
|
package/dist/store-dir.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store-dir.js","sourceRoot":"","sources":["../src/store-dir.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n closeSync,\n fsyncSync,\n linkSync,\n mkdirSync,\n openSync,\n readdirSync,\n readFileSync,\n statSync,\n unlinkSync,\n writeSync,\n} from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport { randomBytes } from 'node:crypto';\nimport process from 'node:process';\n\nimport {\n HashCollisionError,\n SmeltError,\n StoreCorruptionError,\n StoreFormatError,\n UnknownHashError,\n} from './errors.ts';\nimport { contentHash } from './hash.ts';\nimport { retrieveStats } from './stats.ts';\nimport type { RawRetrieveCounters } from './stats.ts';\nimport type { ElisionStore, RetrieveStats } from './types.ts';\n\n/**\n * The format marker every store directory carries, and the one version this code\n * understands. A future layout is a new version, refused loudly by old code — never a\n * quiet reinterpretation of someone's stored bytes.\n */\nexport const DIRECTORY_STORE_FORMAT = 'smelt-elision-store';\nexport const DIRECTORY_STORE_VERSION = 1;\n\n/**\n * What a storage key may look like. `contentHash` produces 16 lowercase hex characters;\n * the pattern is wider so an injected test hash still works, and strict enough that a\n * key can never traverse out of `blobs/` or collide with `format.json`.\n */\nconst KEY_PATTERN = /^[0-9a-f]{4,128}$/;\n\n/** One journal line: a kind, a space, and the hash as a JSON string literal. */\nconst LOG_LINE = /^(hit|miss|corrupt) (\"(?:[^\"\\\\]|\\\\.)*\")$/;\n\n/** See {@link MemoryElisionStoreOptions} in `store.ts` — same escape hatch, same reason. */\nexport interface DirectoryElisionStoreOptions {\n /**\n * Override the hash function, so the collision branch — unreachable with sha256 —\n * can be tested. Production has no reason to pass this.\n */\n readonly hash?: (content: string) => string;\n}\n\n/**\n * A persistent {@link ElisionStore} over a content-addressed directory. `node:fs` only —\n * no SQLite, no new dependency, nothing that phones home. Elisions put here outlive the\n * process, so a long-lived agent session can `retrieve()` across restarts.\n *\n * ## Storage layout\n *\n * ```text\n * <root>/\n * format.json { \"format\": \"smelt-elision-store\", \"version\": 1 } — refused if unknown\n * blobs/<hash> one file per elision: the exact UTF-8 bytes, named by their content hash\n * tmp/ staging for atomic writes; never read, safe to sweep\n * retrievals.log append-only journal: `hit \"<hash>\"` | `miss \"<hash>\"` | `corrupt \"<hash>\"`\n * ```\n *\n * **Nothing lives in memory.** Every read — `stats()` included — comes off the disk, so\n * two instances over the same directory (two processes, or one process before and after\n * a restart) always agree. `stats()` is a scan; elision counts per session are small and\n * retrieval is the model asking for material back, which is rare by design.\n *\n * ## Durability\n *\n * - **Writes are crash-safe.** A blob is written to `tmp/`, `fsync`ed, then `link(2)`ed\n * into `blobs/` — an atomic, no-clobber publish. A torn write dies in `tmp/`, where\n * nothing looks; a name in `blobs/` always refers to a fully written file.\n * - **`fsync` is only as strong as the platform makes it.** Every flush here is Node's\n * `fsyncSync`, which is libuv's `uv_fs_fsync`, and what that reaches the hardware with\n * differs by platform. On Apple it is strong: libuv knows macOS's own `fsync(2)` only\n * hands the write to the drive, so its `__APPLE__` branch issues\n * `fcntl(fd, F_FULLFSYNC)` — a real drive-cache flush — before falling back to\n * `F_BARRIERFSYNC` and then plain `fsync(2)`. Measured here (Node 26, libuv 1.52,\n * internal APFS SSD): an 11-byte append costs 0.01 ms unflushed, 5.5 ms through\n * `fsyncSync`, and 0.10 ms through a raw `fsync(2)` against 4.8 ms through a raw\n * `F_FULLFSYNC` — the cost says which syscall is being made. Everywhere else libuv\n * calls plain `fsync(2)`, which is as durable as the drive's honesty about its own\n * write cache. So a **power loss** can lose a blob or a journal line this code has\n * already `fsync`ed and reported as written on any non-Apple platform whose drive\n * lies, and on Apple only where `F_FULLFSYNC` itself fails and libuv degrades\n * silently — some non-APFS and network mounts. A **process** crash cannot lose one\n * anywhere: the bytes are in the page cache and the publish is still atomic. Nothing\n * is ever handed back unverified either way, so the worst a lost blob can produce is\n * {@link UnknownHashError} — never wrong bytes presented as right ones. \"Crash-safe\"\n * is the claim, deliberately not \"power-loss-proof\": the durability is real, it is\n * just not unconditional.\n * - **Reads verify.** `retrieve()`, `peek()` and `has()` re-hash the bytes and refuse a\n * mismatch with {@link StoreCorruptionError} — a damaged blob is never handed back as\n * a retrieval nor reported as present, and \"we hold damaged bytes\" is distinct from\n * {@link UnknownHashError}'s \"never existed\". The guard in\n * `test/guards/persistent-store.test.ts` watches this.\n * - **Counters survive a restart.** Every `retrieve()` appends one `fsync`ed line to\n * `retrievals.log`, and `stats()` is a fold over it — so `expansionRate` stays\n * meaningful across a whole session, not just one process. A crash in the middle of\n * an append can tear at most that one line; a torn tail is skipped, costing at most\n * the single count that was being written when the process died.\n * - **Concurrent writers are safe.** `link(2)` refuses to clobber, so two processes\n * putting at once race to publish and the loser verifies byte-for-byte agreement with\n * the winner — identical content dedupes, different content under one hash is a\n * {@link HashCollisionError}. Journal appends use `O_APPEND`. Tested with two real\n * processes in `test/store-dir.test.ts`.\n *\n * ## No eviction\n *\n * Same rule as {@link MemoryElisionStore}: no cap, no LRU, no `clear()`. A store that\n * can forget turns Law 3 into \"reversible, usually\". Elided text is smaller than the\n * session that produced it; if disk pressure ever forces a cap, retrieval of an evicted\n * hash must throw a distinct \"evicted\" error — never {@link UnknownHashError} — so the\n * model can tell \"we lost it\" from \"never existed\". Today there is no such error because\n * there is no such cap.\n *\n * ## Two deliberate choices around the edges\n *\n * - **The root is resolved to an absolute path at construction.** Every later path is\n * joined from that, so a `process.chdir()` after construction cannot silently\n * re-target the store — the bytes a relative-rooted store put before a chdir would\n * otherwise be unreachable after it, which reads exactly like data loss.\n * - **A failed journal append never withholds intact bytes.** `retrieve()`'s order of\n * business is: read, verify, count, return. When the *count* cannot be written (a\n * read-only journal, a full disk), the bytes are still returned — they are verified\n * and the caller asked for them; refusing would turn a bookkeeping failure into\n * Law 3 breaking. The failure is surfaced distinctly instead: a\n * `process.emitWarning` with name `SmeltCounterWriteFailure`, so \"your retrieval\n * worked\" and \"your counters just went quiet\" stay two separate facts. The same\n * applies to the `miss`/`corrupt` journal lines: the store's own error for the\n * lookup still wins over the journal's I/O error.\n */\nexport class DirectoryElisionStore implements ElisionStore {\n readonly #blobsDir: string;\n readonly #tmpDir: string;\n readonly #logPath: string;\n readonly #hash: (content: string) => string;\n\n constructor(root: string, options: DirectoryElisionStoreOptions = {}) {\n this.#hash = options.hash ?? contentHash;\n // Resolve NOW, against the working directory the caller constructed with — a\n // later chdir must never re-point an already-constructed store. See the class doc.\n const absoluteRoot = resolve(root);\n this.#blobsDir = join(absoluteRoot, 'blobs');\n this.#tmpDir = join(absoluteRoot, 'tmp');\n this.#logPath = join(absoluteRoot, 'retrievals.log');\n const markerPath = join(absoluteRoot, 'format.json');\n // Validate before mutating: a directory carrying a marker this code does not\n // understand is refused with the directory exactly as it was found — no blobs/,\n // no tmp/, no staged temp file created inside someone else's layout.\n const existing = this.#readMarker(markerPath);\n if (existing !== undefined) this.#verifyMarker(markerPath, existing);\n mkdirSync(this.#blobsDir, { recursive: true });\n mkdirSync(this.#tmpDir, { recursive: true });\n this.#claimFormat(markerPath);\n }\n\n put(content: string): string {\n const hash = this.#hash(content);\n if (!KEY_PATTERN.test(hash)) {\n throw new SmeltError(\n `smelt: hash \"${hash}\" is not usable as a storage key — it must match ` +\n `${String(KEY_PATTERN)} so it can name a file inside blobs/ and nothing else.`,\n );\n }\n const existing = this.#readBlob(hash);\n if (existing !== undefined) {\n // Verify the stored bytes before comparing: a damaged blob is corruption, not a\n // collision. Only intact bytes that still differ earn HashCollisionError.\n if (this.#hash(existing) !== hash) throw new StoreCorruptionError(hash);\n if (existing !== content) throw new HashCollisionError(hash);\n return hash;\n }\n const tmpPath = this.#writeTemp(content);\n try {\n // link(2) is the atomic, no-clobber publish: it fails with EEXIST rather than\n // overwrite, so a concurrent writer can never silently replace someone's bytes.\n linkSync(tmpPath, join(this.#blobsDir, hash));\n } catch (error) {\n if ((error as { code?: string }).code !== 'EEXIST') throw error;\n // Another writer published this hash between our existence check and our link.\n // Same bytes: idempotent put, done. Damaged or vanished bytes: corruption — the\n // store was torn or edited outside smelt. Intact different bytes: a collision.\n const winner = this.#readBlob(hash);\n if (winner === undefined || this.#hash(winner) !== hash) {\n throw new StoreCorruptionError(hash);\n }\n if (winner !== content) throw new HashCollisionError(hash);\n } finally {\n unlinkSync(tmpPath);\n }\n fsyncDirBestEffort(this.#blobsDir);\n return hash;\n }\n\n peek(hash: string): string | undefined {\n const content = this.#readBlob(hash);\n if (content === undefined) return undefined;\n if (this.#hash(content) !== hash) throw new StoreCorruptionError(hash);\n return content;\n }\n\n retrieve(hash: string): string {\n const content = this.#readBlob(hash);\n if (content === undefined) {\n this.#appendLogCounting('miss', hash);\n throw new UnknownHashError(hash);\n }\n if (this.#hash(content) !== hash) {\n this.#appendLogCounting('corrupt', hash);\n throw new StoreCorruptionError(hash);\n }\n this.#appendLogCounting('hit', hash);\n return content;\n }\n\n /**\n * Whether this hash can be **retrieved** — verified, exactly as {@link peek} and\n * {@link retrieve} verify, because it is `peek()`.\n *\n * `has()` used to be the one read that skipped verification: a damaged blob answered\n * `true` and then threw {@link StoreCorruptionError} on the very next line, so a\n * consumer that checked before retrieving was told a lie by the cheaper call. The two\n * answers now come from one place and cannot drift: `true` means the bytes are there\n * and hash to their name, `false` means this store never held them, and damage is\n * raised rather than hidden behind a boolean — the same distinction `peek()` draws\n * between \"we hold damaged bytes\" and \"never existed\".\n *\n * It stays uncounted: a check is not the model asking for material back, and counting\n * one would inflate `retrieveCalls` and with it the expansion rate, which is the one\n * number this library exists to keep honest. So no journal line is written here, not\n * even for the corrupt case — `retrieve()` journals that when the model asks.\n *\n * @throws {StoreCorruptionError} when the stored bytes do not hash to their name.\n */\n has(hash: string): boolean {\n return this.peek(hash) !== undefined;\n }\n\n /**\n * The five directly-observed counts, every one read off the disk — a scan of\n * `blobs/` plus a fold over `retrievals.log`. See {@link RawRetrieveCounters}; the\n * derived half of the stats comes from the shared `retrieveStats()`, never here.\n */\n rawCounters(): RawRetrieveCounters {\n let elisionsStored = 0;\n let bytesStored = 0;\n for (const entry of readdirSync(this.#blobsDir)) {\n if (!KEY_PATTERN.test(entry)) continue; // `.DS_Store` and friends are not blobs\n elisionsStored += 1;\n bytesStored += statSync(join(this.#blobsDir, entry)).size;\n }\n\n let retrieveCalls = 0;\n let misses = 0;\n const hits = new Set<string>();\n for (const line of this.#readLog().split('\\n')) {\n const match = LOG_LINE.exec(line);\n if (match === null) continue; // a torn tail from a crash mid-append, or blank\n retrieveCalls += 1;\n if (match[1] === 'miss') misses += 1;\n else if (match[1] === 'hit') hits.add(JSON.parse(match[2]!) as string);\n }\n\n return { elisionsStored, bytesStored, retrieveCalls, uniqueRetrieved: hits.size, misses };\n }\n\n stats(): RetrieveStats {\n return retrieveStats(this.rawCounters());\n }\n\n /** The blob's exact content, or `undefined` when no such blob is stored. */\n #readBlob(hash: string): string | undefined {\n if (!KEY_PATTERN.test(hash)) return undefined; // never a path component\n try {\n return readFileSync(join(this.#blobsDir, hash), 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n }\n\n /** Write content to a unique file in `tmp/`, fsynced, and return its path. */\n #writeTemp(content: string): string {\n const tmpPath = join(this.#tmpDir, `${String(process.pid)}-${randomBytes(8).toString('hex')}`);\n const fd = openSync(tmpPath, 'wx');\n try {\n // writeSync may write fewer bytes than asked; loop, or a short write would be\n // fsynced and published under the full content's hash as a torn blob.\n const bytes = Buffer.from(content, 'utf8');\n let written = 0;\n while (written < bytes.length) {\n written += writeSync(fd, bytes, written);\n }\n fsyncSync(fd);\n } finally {\n closeSync(fd);\n }\n return tmpPath;\n }\n\n /**\n * A journal append on the `retrieve()` path — counting, not custody. A failure here\n * must never decide whether the caller gets its verified bytes (or its true error),\n * so it is caught and surfaced as a distinct `process.emitWarning` — see the class\n * doc, and the read-only-journal case in `test/store-dir.test.ts`.\n */\n #appendLogCounting(kind: 'hit' | 'miss' | 'corrupt', hash: string): void {\n try {\n this.#appendLog(kind, hash);\n } catch (error) {\n process.emitWarning(\n `smelt: could not journal a \"${kind}\" for hash \"${hash}\" in ${this.#logPath} ` +\n `(${error instanceof Error ? error.message : String(error)}). The retrieval ` +\n `itself is unaffected, but this count is lost — retrieveCalls and ` +\n `expansionRate now UNDER-report until the journal is writable again.`,\n 'SmeltCounterWriteFailure',\n );\n }\n }\n\n /**\n * One durable journal line. The hash is JSON-encoded because `retrieve()` takes it\n * from the model verbatim — a hash containing a newline must not forge a second line.\n * The record starts with its own newline so a torn tail from an earlier crash — a\n * partial record with no trailing newline — can never bleed into this one: the tear\n * stays on its own line and is skipped by `stats()`, as blank lines are.\n */\n #appendLog(kind: 'hit' | 'miss' | 'corrupt', hash: string): void {\n const fd = openSync(this.#logPath, 'a');\n try {\n const record = Buffer.from(`\\n${kind} ${JSON.stringify(hash)}\\n`, 'utf8');\n let written = 0;\n while (written < record.length) {\n written += writeSync(fd, record, written);\n }\n fsyncSync(fd);\n } finally {\n closeSync(fd);\n }\n }\n\n #readLog(): string {\n try {\n return readFileSync(this.#logPath, 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return '';\n throw error;\n }\n }\n\n /**\n * Write the format marker if this directory has none, or verify the one it has.\n * Creation is atomic (write to `tmp/`, then `link`), so a concurrent creator never\n * observes a half-written marker. The constructor pre-verified any pre-existing\n * marker; the verify here catches only a concurrent creator's claim.\n */\n #claimFormat(markerPath: string): void {\n const claim = (): string | undefined => {\n const body = `${JSON.stringify({\n format: DIRECTORY_STORE_FORMAT,\n version: DIRECTORY_STORE_VERSION,\n })}\\n`;\n const tmpPath = this.#writeTemp(body);\n try {\n linkSync(tmpPath, markerPath);\n return undefined; // claimed by us; nothing to verify\n } catch (error) {\n if ((error as { code?: string }).code !== 'EEXIST') throw error;\n return readFileSync(markerPath, 'utf8');\n } finally {\n unlinkSync(tmpPath);\n }\n };\n\n const existing = claim();\n if (existing === undefined) return;\n this.#verifyMarker(markerPath, existing);\n }\n\n /** The marker's body, or `undefined` when the directory carries none. */\n #readMarker(markerPath: string): string | undefined {\n try {\n return readFileSync(markerPath, 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n }\n\n /** Refuse a marker this version of smelt does not understand. */\n #verifyMarker(markerPath: string, existing: string): void {\n let parsed: { format?: unknown; version?: unknown };\n try {\n parsed = JSON.parse(existing) as { format?: unknown; version?: unknown };\n } catch {\n throw new StoreFormatError(\n `smelt: \"${markerPath}\" is not parseable JSON, so this directory cannot be ` +\n `trusted as an elision store. Refusing to read or write it.`,\n );\n }\n if (parsed.format !== DIRECTORY_STORE_FORMAT || parsed.version !== DIRECTORY_STORE_VERSION) {\n throw new StoreFormatError(\n `smelt: \"${markerPath}\" declares format ${JSON.stringify(parsed.format)} ` +\n `version ${JSON.stringify(parsed.version)}; this code understands ` +\n `\"${DIRECTORY_STORE_FORMAT}\" version ${String(DIRECTORY_STORE_VERSION)}. ` +\n `Refusing to reinterpret someone else's layout.`,\n );\n }\n }\n}\n\n/**\n * Flush the directory entry after a publish, so the *name* survives a crash as well as\n * the bytes. Where the platform refuses to fsync a directory (Windows does), the publish\n * is still atomic — only the durability of the directory entry falls back to the OS's\n * own schedule. Only that refusal is swallowed: a real I/O failure (`EIO`) propagates,\n * because \"the disk could not flush\" must never be reported as a successful put.\n *\n * Where it does work it is the same `fsyncSync` the blob's own flush uses, and so is\n * exactly as strong as that — see the durability note on {@link DirectoryElisionStore}\n * for what that means per platform.\n */\nfunction fsyncDirBestEffort(path: string): void {\n let fd: number;\n try {\n fd = openSync(path, 'r');\n } catch {\n return; // the platform refuses to even open a directory for reading (Windows)\n }\n try {\n fsyncSync(fd);\n } catch (error) {\n const code = (error as { code?: string }).code;\n // EINVAL/ENOTSUP/EPERM/EBADF: the platform refuses to fsync a directory — see the\n // doc comment. Anything else (EIO above all) is a genuine write failure.\n if (code !== 'EINVAL' && code !== 'ENOTSUP' && code !== 'EPERM' && code !== 'EBADF') {\n throw error;\n }\n } finally {\n closeSync(fd);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"store-dir.js","sourceRoot":"","sources":["../src/store-dir.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,SAAS,EACT,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,YAAY,EACZ,QAAQ,EACR,UAAU,EACV,SAAS,GACV,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,OAAO,MAAM,cAAc,CAAC;AAEnC,OAAO,EACL,kBAAkB,EAClB,UAAU,EACV,oBAAoB,EACpB,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAIvD;;;;GAIG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,qBAAqB,CAAC;AAC5D,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC;AAEzC;;;;GAIG;AACH,MAAM,WAAW,GAAG,mBAAmB,CAAC;AAExC,gFAAgF;AAChF,MAAM,QAAQ,GAAG,0CAA0C,CAAC;AAE5D;;;;;;GAMG;AACH,MAAM,QAAQ,GAAG,+CAA+C,CAAC;AAWjE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqFG;AACH,MAAM,OAAO,qBAAqB;IACvB,SAAS,CAAS;IAClB,OAAO,CAAS;IAChB,QAAQ,CAAS;IACjB,KAAK,CAA8B;IAE5C,YAAY,IAAY,EAAE,OAAO,GAAiC,EAAE;QAClE,IAAI,CAAC,KAAK,GAAG,OAAO,CAAC,IAAI,IAAI,WAAW,CAAC;QACzC,6EAA6E;QAC7E,mFAAmF;QACnF,MAAM,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QACnC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;QAC7C,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,YAAY,EAAE,KAAK,CAAC,CAAC;QACzC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,YAAY,EAAE,gBAAgB,CAAC,CAAC;QACrD,MAAM,UAAU,GAAG,IAAI,CAAC,YAAY,EAAE,aAAa,CAAC,CAAC;QACrD,6EAA6E;QAC7E,gFAAgF;QAChF,qEAAqE;QACrE,MAAM,QAAQ,GAAG,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC;QAC9C,IAAI,QAAQ,KAAK,SAAS;YAAE,IAAI,CAAC,aAAa,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;QACrE,SAAS,CAAC,IAAI,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC7C,IAAI,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC;IAChC,CAAC;IAED,GAAG,CAAC,OAAe,EAAE,MAAsB;QACzC,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QACpC,+EAA+E;QAC/E,yEAAyE;QACzE,IAAI,MAAM,KAAK,SAAS;YAAE,IAAI,CAAC,kBAAkB,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;QAC5E,OAAO,IAAI,CAAC;IACd,CAAC;IAED,qEAAqE;IACrE,QAAQ,CAAC,OAAe;QACtB,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QACjC,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAC5B,MAAM,IAAI,UAAU,CAClB,gBAAgB,IAAI,mDAAmD;gBACrE,GAAG,MAAM,CAAC,WAAW,CAAC,wDAAwD,CACjF,CAAC;QACJ,CAAC;QACD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACtC,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC3B,gFAAgF;YAChF,0EAA0E;YAC1E,IAAI,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,IAAI;gBAAE,MAAM,IAAI,oBAAoB,CAAC,IAAI,CAAC,CAAC;YACxE,IAAI,QAAQ,KAAK,OAAO;gBAAE,MAAM,IAAI,kBAAkB,CAAC,IAAI,CAAC,CAAC;YAC7D,OAAO,IAAI,CAAC;QACd,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;QACzC,IAAI,CAAC;YACH,8EAA8E;YAC9E,gFAAgF;YAChF,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC;QAChD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAK,KAA2B,CAAC,IAAI,KAAK,QAAQ;gBAAE,MAAM,KAAK,CAAC;YAChE,+EAA+E;YAC/E,gFAAgF;YAChF,+EAA+E;YAC/E,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YACpC,IAAI,MAAM,KAAK,SAAS,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;gBACxD,MAAM,IAAI,oBAAoB,CAAC,IAAI,CAAC,CAAC;YACvC,CAAC;YACD,IAAI,MAAM,KAAK,OAAO;gBAAE,MAAM,IAAI,kBAAkB,CAAC,IAAI,CAAC,CAAC;QAC7D,CAAC;gBAAS,CAAC;YACT,UAAU,CAAC,OAAO,CAAC,CAAC;QACtB,CAAC;QACD,kBAAkB,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACnC,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,CAAC,IAAY;QACf,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACrC,IAAI,OAAO,KAAK,SAAS;YAAE,OAAO,SAAS,CAAC;QAC5C,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,IAAI;YAAE,MAAM,IAAI,oBAAoB,CAAC,IAAI,CAAC,CAAC;QACvE,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,QAAQ,CAAC,IAAY;QACnB,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACrC,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;YAC1B,IAAI,CAAC,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;YACtC,MAAM,IAAI,gBAAgB,CAAC,IAAI,CAAC,CAAC;QACnC,CAAC;QACD,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC;YACjC,IAAI,CAAC,kBAAkB,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;YACzC,MAAM,IAAI,oBAAoB,CAAC,IAAI,CAAC,CAAC;QACvC,CAAC;QACD,IAAI,CAAC,kBAAkB,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;QACrC,OAAO,OAAO,CAAC;IACjB,CAAC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,GAAG,CAAC,IAAY;QACd,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,SAAS,CAAC;IACvC,CAAC;IAED;;;;OAIG;IACH,WAAW;QACT,IAAI,cAAc,GAAG,CAAC,CAAC;QACvB,IAAI,WAAW,GAAG,CAAC,CAAC;QACpB,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC;YAChD,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC;gBAAE,SAAS,CAAC,wCAAwC;YAChF,cAAc,IAAI,CAAC,CAAC;YACpB,WAAW,IAAI,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;QAC5D,CAAC;QAED,IAAI,aAAa,GAAG,CAAC,CAAC;QACtB,IAAI,MAAM,GAAG,CAAC,CAAC;QACf,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;QAC/B,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/C,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAClC,IAAI,KAAK,KAAK,IAAI;gBAAE,SAAS,CAAC,gDAAgD;YAC9E,aAAa,IAAI,CAAC,CAAC;YACnB,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,MAAM;gBAAE,MAAM,IAAI,CAAC,CAAC;iBAChC,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,KAAK;gBAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAE,CAAW,CAAC,CAAC;QACzE,CAAC;QAED,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,aAAa,EAAE,eAAe,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;IAC5F,CAAC;IAED,KAAK;QACH,OAAO,aAAa,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED;;;;OAIG;IACH,MAAM;QACJ,MAAM,IAAI,GAAqC,EAAE,CAAC;QAClD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;QAC/B,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/C,MAAM,GAAG,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAChC,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;gBACjB,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAE,CAAW,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAE,CAAW,EAAE,CAAC,CAAC;gBACxF,SAAS;YACX,CAAC;YACD,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACpC,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,KAAK;gBAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAE,CAAW,CAAC,CAAC;QAC5F,CAAC;QACD,OAAO,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAChC,CAAC;IAED,4EAA4E;IAC5E,SAAS,CAAC,IAAY;QACpB,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,SAAS,CAAC,CAAC,yBAAyB;QACxE,IAAI,CAAC;YACH,OAAO,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC;QAC1D,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAK,KAA2B,CAAC,IAAI,KAAK,QAAQ;gBAAE,OAAO,SAAS,CAAC;YACrE,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,8EAA8E;IAC9E,UAAU,CAAC,OAAe;QACxB,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC/F,MAAM,EAAE,GAAG,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QACnC,IAAI,CAAC;YACH,8EAA8E;YAC9E,sEAAsE;YACtE,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC3C,IAAI,OAAO,GAAG,CAAC,CAAC;YAChB,OAAO,OAAO,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC;gBAC9B,OAAO,IAAI,SAAS,CAAC,EAAE,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC;YAC3C,CAAC;YACD,SAAS,CAAC,EAAE,CAAC,CAAC;QAChB,CAAC;gBAAS,CAAC;YACT,SAAS,CAAC,EAAE,CAAC,CAAC;QAChB,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC;IAED;;;;;OAKG;IACH,kBAAkB,CAAC,IAAwC,EAAE,IAAY,EAAE,IAAa;QACtF,IAAI,CAAC;YACH,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QACpC,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,WAAW,CACjB,+BAA+B,IAAI,eAAe,IAAI,QAAQ,IAAI,CAAC,QAAQ,GAAG;gBAC5E,IAAI,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,mBAAmB;gBAC7E,mEAAmE;gBACnE,qEAAqE,EACvE,0BAA0B,CAC3B,CAAC;QACJ,CAAC;IACH,CAAC;IAED;;;;;;OAMG;IACH,UAAU,CAAC,IAAwC,EAAE,IAAY,EAAE,IAAa;QAC9E,MAAM,EAAE,GAAG,QAAQ,CAAC,IAAI,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;QACxC,IAAI,CAAC;YACH,MAAM,MAAM,GACV,IAAI,KAAK,SAAS;gBAChB,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;gBAC9B,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;YACzD,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YAC9D,IAAI,OAAO,GAAG,CAAC,CAAC;YAChB,OAAO,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;gBAC/B,OAAO,IAAI,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;YAC5C,CAAC;YACD,SAAS,CAAC,EAAE,CAAC,CAAC;QAChB,CAAC;gBAAS,CAAC;YACT,SAAS,CAAC,EAAE,CAAC,CAAC;QAChB,CAAC;IACH,CAAC;IAED,QAAQ;QACN,IAAI,CAAC;YACH,OAAO,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAC7C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAK,KAA2B,CAAC,IAAI,KAAK,QAAQ;gBAAE,OAAO,EAAE,CAAC;YAC9D,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED;;;;;OAKG;IACH,YAAY,CAAC,UAAkB;QAC7B,MAAM,KAAK,GAAG,GAAuB,EAAE;YACrC,MAAM,IAAI,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC;gBAC7B,MAAM,EAAE,sBAAsB;gBAC9B,OAAO,EAAE,uBAAuB;aACjC,CAAC,IAAI,CAAC;YACP,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;YACtC,IAAI,CAAC;gBACH,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;gBAC9B,OAAO,SAAS,CAAC,CAAC,mCAAmC;YACvD,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,IAAK,KAA2B,CAAC,IAAI,KAAK,QAAQ;oBAAE,MAAM,KAAK,CAAC;gBAChE,OAAO,YAAY,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;YAC1C,CAAC;oBAAS,CAAC;gBACT,UAAU,CAAC,OAAO,CAAC,CAAC;YACtB,CAAC;QACH,CAAC,CAAC;QAEF,MAAM,QAAQ,GAAG,KAAK,EAAE,CAAC;QACzB,IAAI,QAAQ,KAAK,SAAS;YAAE,OAAO;QACnC,IAAI,CAAC,aAAa,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;IAC3C,CAAC;IAED,yEAAyE;IACzE,WAAW,CAAC,UAAkB;QAC5B,IAAI,CAAC;YACH,OAAO,YAAY,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;QAC1C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAK,KAA2B,CAAC,IAAI,KAAK,QAAQ;gBAAE,OAAO,SAAS,CAAC;YACrE,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,iEAAiE;IACjE,aAAa,CAAC,UAAkB,EAAE,QAAgB;QAChD,IAAI,MAA+C,CAAC;QACpD,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAA4C,CAAC;QAC3E,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,IAAI,gBAAgB,CACxB,WAAW,UAAU,uDAAuD;gBAC1E,4DAA4D,CAC/D,CAAC;QACJ,CAAC;QACD,IAAI,MAAM,CAAC,MAAM,KAAK,sBAAsB,IAAI,MAAM,CAAC,OAAO,KAAK,uBAAuB,EAAE,CAAC;YAC3F,MAAM,IAAI,gBAAgB,CACxB,WAAW,UAAU,qBAAqB,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG;gBACxE,WAAW,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,0BAA0B;gBACnE,IAAI,sBAAsB,aAAa,MAAM,CAAC,uBAAuB,CAAC,IAAI;gBAC1E,gDAAgD,CACnD,CAAC;QACJ,CAAC;IACH,CAAC;CACF;AAED;;;;;;;;;;GAUG;AACH,SAAS,kBAAkB,CAAC,IAAY;IACtC,IAAI,EAAU,CAAC;IACf,IAAI,CAAC;QACH,EAAE,GAAG,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,sEAAsE;IAChF,CAAC;IACD,IAAI,CAAC;QACH,SAAS,CAAC,EAAE,CAAC,CAAC;IAChB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,GAAI,KAA2B,CAAC,IAAI,CAAC;QAC/C,kFAAkF;QAClF,yEAAyE;QACzE,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,OAAO,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;YACpF,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;YAAS,CAAC;QACT,SAAS,CAAC,EAAE,CAAC,CAAC;IAChB,CAAC;AACH,CAAC","sourcesContent":["import {\n closeSync,\n fsyncSync,\n linkSync,\n mkdirSync,\n openSync,\n readdirSync,\n readFileSync,\n statSync,\n unlinkSync,\n writeSync,\n} from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport { randomBytes } from 'node:crypto';\nimport process from 'node:process';\n\nimport {\n HashCollisionError,\n SmeltError,\n StoreCorruptionError,\n StoreFormatError,\n UnknownHashError,\n} from './errors.ts';\nimport { contentHash } from './hash.ts';\nimport { retrieveStats, ruleLedger } from './stats.ts';\nimport type { RawRetrieveCounters } from './stats.ts';\nimport type { ElisionReason, ElisionStore, RetrieveStats, RuleLedgerEntry } from './types.ts';\n\n/**\n * The format marker every store directory carries, and the one version this code\n * understands. A future layout is a new version, refused loudly by old code — never a\n * quiet reinterpretation of someone's stored bytes.\n */\nexport const DIRECTORY_STORE_FORMAT = 'smelt-elision-store';\nexport const DIRECTORY_STORE_VERSION = 1;\n\n/**\n * What a storage key may look like. `contentHash` produces 16 lowercase hex characters;\n * the pattern is wider so an injected test hash still works, and strict enough that a\n * key can never traverse out of `blobs/` or collide with `format.json`.\n */\nconst KEY_PATTERN = /^[0-9a-f]{4,128}$/;\n\n/** One counter line: a kind, a space, and the hash as a JSON string literal. */\nconst LOG_LINE = /^(hit|miss|corrupt) (\"(?:[^\"\\\\]|\\\\.)*\")$/;\n\n/**\n * One ledger line: `put`, the hash, and the rule id — both JSON string literals. A\n * separate pattern from {@link LOG_LINE} on purpose: the counter fold matches only\n * counter lines and skips these, exactly as a reader that predates the ledger skips a\n * line it does not know, so a directory written by this version reads as the same\n * counters under the previous one (`test/ledger.test.ts` pins that).\n */\nconst PUT_LINE = /^put (\"(?:[^\"\\\\]|\\\\.)*\") (\"(?:[^\"\\\\]|\\\\.)*\")$/;\n\n/** See {@link MemoryElisionStoreOptions} in `store.ts` — same escape hatch, same reason. */\nexport interface DirectoryElisionStoreOptions {\n /**\n * Override the hash function, so the collision branch — unreachable with sha256 —\n * can be tested. Production has no reason to pass this.\n */\n readonly hash?: (content: string) => string;\n}\n\n/**\n * A persistent {@link ElisionStore} over a content-addressed directory. `node:fs` only —\n * no SQLite, no new dependency, nothing that phones home. Elisions put here outlive the\n * process, so a long-lived agent session can `retrieve()` across restarts.\n *\n * ## Storage layout\n *\n * ```text\n * <root>/\n * format.json { \"format\": \"smelt-elision-store\", \"version\": 1 } — refused if unknown\n * blobs/<hash> one file per elision: the exact UTF-8 bytes, named by their content hash\n * tmp/ staging for atomic writes; never read, safe to sweep\n * retrievals.log append-only journal: `hit \"<hash>\"` | `miss \"<hash>\"` | `corrupt \"<hash>\"`\n * | `put \"<hash>\" \"<rule>\"` (the ledger: which rule cut what)\n * ```\n *\n * **Nothing lives in memory.** Every read — `stats()` included — comes off the disk, so\n * two instances over the same directory (two processes, or one process before and after\n * a restart) always agree. `stats()` is a scan; elision counts per session are small and\n * retrieval is the model asking for material back, which is rare by design.\n *\n * ## Durability\n *\n * - **Writes are crash-safe.** A blob is written to `tmp/`, `fsync`ed, then `link(2)`ed\n * into `blobs/` — an atomic, no-clobber publish. A torn write dies in `tmp/`, where\n * nothing looks; a name in `blobs/` always refers to a fully written file.\n * - **`fsync` is only as strong as the platform makes it.** Every flush here is Node's\n * `fsyncSync`, which is libuv's `uv_fs_fsync`, and what that reaches the hardware with\n * differs by platform. On Apple it is strong: libuv knows macOS's own `fsync(2)` only\n * hands the write to the drive, so its `__APPLE__` branch issues\n * `fcntl(fd, F_FULLFSYNC)` — a real drive-cache flush — before falling back to\n * `F_BARRIERFSYNC` and then plain `fsync(2)`. Measured here (Node 26, libuv 1.52,\n * internal APFS SSD): an 11-byte append costs 0.01 ms unflushed, 5.5 ms through\n * `fsyncSync`, and 0.10 ms through a raw `fsync(2)` against 4.8 ms through a raw\n * `F_FULLFSYNC` — the cost says which syscall is being made. Everywhere else libuv\n * calls plain `fsync(2)`, which is as durable as the drive's honesty about its own\n * write cache. So a **power loss** can lose a blob or a journal line this code has\n * already `fsync`ed and reported as written on any non-Apple platform whose drive\n * lies, and on Apple only where `F_FULLFSYNC` itself fails and libuv degrades\n * silently — some non-APFS and network mounts. A **process** crash cannot lose one\n * anywhere: the bytes are in the page cache and the publish is still atomic. Nothing\n * is ever handed back unverified either way, so the worst a lost blob can produce is\n * {@link UnknownHashError} — never wrong bytes presented as right ones. \"Crash-safe\"\n * is the claim, deliberately not \"power-loss-proof\": the durability is real, it is\n * just not unconditional.\n * - **Reads verify.** `retrieve()`, `peek()` and `has()` re-hash the bytes and refuse a\n * mismatch with {@link StoreCorruptionError} — a damaged blob is never handed back as\n * a retrieval nor reported as present, and \"we hold damaged bytes\" is distinct from\n * {@link UnknownHashError}'s \"never existed\". The guard in\n * `test/guards/persistent-store.test.ts` watches this.\n * - **Counters survive a restart.** Every `retrieve()` appends one `fsync`ed line to\n * `retrievals.log`, and `stats()` is a fold over it — so `expansionRate` stays\n * meaningful across a whole session, not just one process. A crash in the middle of\n * an append can tear at most that one line; a torn tail is skipped, costing at most\n * the single count that was being written when the process died.\n * - **Concurrent writers are safe.** `link(2)` refuses to clobber, so two processes\n * putting at once race to publish and the loser verifies byte-for-byte agreement with\n * the winner — identical content dedupes, different content under one hash is a\n * {@link HashCollisionError}. Journal appends use `O_APPEND`. Tested with two real\n * processes in `test/store-dir.test.ts`.\n *\n * ## No eviction\n *\n * Same rule as {@link MemoryElisionStore}: no cap, no LRU, no `clear()`. A store that\n * can forget turns Law 3 into \"reversible, usually\". Elided text is smaller than the\n * session that produced it; if disk pressure ever forces a cap, retrieval of an evicted\n * hash must throw a distinct \"evicted\" error — never {@link UnknownHashError} — so the\n * model can tell \"we lost it\" from \"never existed\". Today there is no such error because\n * there is no such cap.\n *\n * ## Two deliberate choices around the edges\n *\n * - **The root is resolved to an absolute path at construction.** Every later path is\n * joined from that, so a `process.chdir()` after construction cannot silently\n * re-target the store — the bytes a relative-rooted store put before a chdir would\n * otherwise be unreachable after it, which reads exactly like data loss.\n * - **A failed journal append never withholds intact bytes.** `retrieve()`'s order of\n * business is: read, verify, count, return. When the *count* cannot be written (a\n * read-only journal, a full disk), the bytes are still returned — they are verified\n * and the caller asked for them; refusing would turn a bookkeeping failure into\n * Law 3 breaking. The failure is surfaced distinctly instead: a\n * `process.emitWarning` with name `SmeltCounterWriteFailure`, so \"your retrieval\n * worked\" and \"your counters just went quiet\" stay two separate facts. The same\n * applies to the `miss`/`corrupt` journal lines: the store's own error for the\n * lookup still wins over the journal's I/O error.\n */\nexport class DirectoryElisionStore implements ElisionStore {\n readonly #blobsDir: string;\n readonly #tmpDir: string;\n readonly #logPath: string;\n readonly #hash: (content: string) => string;\n\n constructor(root: string, options: DirectoryElisionStoreOptions = {}) {\n this.#hash = options.hash ?? contentHash;\n // Resolve NOW, against the working directory the caller constructed with — a\n // later chdir must never re-point an already-constructed store. See the class doc.\n const absoluteRoot = resolve(root);\n this.#blobsDir = join(absoluteRoot, 'blobs');\n this.#tmpDir = join(absoluteRoot, 'tmp');\n this.#logPath = join(absoluteRoot, 'retrievals.log');\n const markerPath = join(absoluteRoot, 'format.json');\n // Validate before mutating: a directory carrying a marker this code does not\n // understand is refused with the directory exactly as it was found — no blobs/,\n // no tmp/, no staged temp file created inside someone else's layout.\n const existing = this.#readMarker(markerPath);\n if (existing !== undefined) this.#verifyMarker(markerPath, existing);\n mkdirSync(this.#blobsDir, { recursive: true });\n mkdirSync(this.#tmpDir, { recursive: true });\n this.#claimFormat(markerPath);\n }\n\n put(content: string, reason?: ElisionReason): string {\n const hash = this.#putBlob(content);\n // The ledger line, after the bytes are safe: attribution is bookkeeping, and a\n // failure to write it is surfaced as a warning rather than a failed put.\n if (reason !== undefined) this.#appendLogCounting('put', hash, reason.rule);\n return hash;\n }\n\n /** The publish itself: verify or write the blob, return its hash. */\n #putBlob(content: string): string {\n const hash = this.#hash(content);\n if (!KEY_PATTERN.test(hash)) {\n throw new SmeltError(\n `smelt: hash \"${hash}\" is not usable as a storage key — it must match ` +\n `${String(KEY_PATTERN)} so it can name a file inside blobs/ and nothing else.`,\n );\n }\n const existing = this.#readBlob(hash);\n if (existing !== undefined) {\n // Verify the stored bytes before comparing: a damaged blob is corruption, not a\n // collision. Only intact bytes that still differ earn HashCollisionError.\n if (this.#hash(existing) !== hash) throw new StoreCorruptionError(hash);\n if (existing !== content) throw new HashCollisionError(hash);\n return hash;\n }\n const tmpPath = this.#writeTemp(content);\n try {\n // link(2) is the atomic, no-clobber publish: it fails with EEXIST rather than\n // overwrite, so a concurrent writer can never silently replace someone's bytes.\n linkSync(tmpPath, join(this.#blobsDir, hash));\n } catch (error) {\n if ((error as { code?: string }).code !== 'EEXIST') throw error;\n // Another writer published this hash between our existence check and our link.\n // Same bytes: idempotent put, done. Damaged or vanished bytes: corruption — the\n // store was torn or edited outside smelt. Intact different bytes: a collision.\n const winner = this.#readBlob(hash);\n if (winner === undefined || this.#hash(winner) !== hash) {\n throw new StoreCorruptionError(hash);\n }\n if (winner !== content) throw new HashCollisionError(hash);\n } finally {\n unlinkSync(tmpPath);\n }\n fsyncDirBestEffort(this.#blobsDir);\n return hash;\n }\n\n peek(hash: string): string | undefined {\n const content = this.#readBlob(hash);\n if (content === undefined) return undefined;\n if (this.#hash(content) !== hash) throw new StoreCorruptionError(hash);\n return content;\n }\n\n retrieve(hash: string): string {\n const content = this.#readBlob(hash);\n if (content === undefined) {\n this.#appendLogCounting('miss', hash);\n throw new UnknownHashError(hash);\n }\n if (this.#hash(content) !== hash) {\n this.#appendLogCounting('corrupt', hash);\n throw new StoreCorruptionError(hash);\n }\n this.#appendLogCounting('hit', hash);\n return content;\n }\n\n /**\n * Whether this hash can be **retrieved** — verified, exactly as {@link peek} and\n * {@link retrieve} verify, because it is `peek()`.\n *\n * `has()` used to be the one read that skipped verification: a damaged blob answered\n * `true` and then threw {@link StoreCorruptionError} on the very next line, so a\n * consumer that checked before retrieving was told a lie by the cheaper call. The two\n * answers now come from one place and cannot drift: `true` means the bytes are there\n * and hash to their name, `false` means this store never held them, and damage is\n * raised rather than hidden behind a boolean — the same distinction `peek()` draws\n * between \"we hold damaged bytes\" and \"never existed\".\n *\n * It stays uncounted: a check is not the model asking for material back, and counting\n * one would inflate `retrieveCalls` and with it the expansion rate, which is the one\n * number this library exists to keep honest. So no journal line is written here, not\n * even for the corrupt case — `retrieve()` journals that when the model asks.\n *\n * @throws {StoreCorruptionError} when the stored bytes do not hash to their name.\n */\n has(hash: string): boolean {\n return this.peek(hash) !== undefined;\n }\n\n /**\n * The five directly-observed counts, every one read off the disk — a scan of\n * `blobs/` plus a fold over `retrievals.log`. See {@link RawRetrieveCounters}; the\n * derived half of the stats comes from the shared `retrieveStats()`, never here.\n */\n rawCounters(): RawRetrieveCounters {\n let elisionsStored = 0;\n let bytesStored = 0;\n for (const entry of readdirSync(this.#blobsDir)) {\n if (!KEY_PATTERN.test(entry)) continue; // `.DS_Store` and friends are not blobs\n elisionsStored += 1;\n bytesStored += statSync(join(this.#blobsDir, entry)).size;\n }\n\n let retrieveCalls = 0;\n let misses = 0;\n const hits = new Set<string>();\n for (const line of this.#readLog().split('\\n')) {\n const match = LOG_LINE.exec(line);\n if (match === null) continue; // a torn tail from a crash mid-append, or blank\n retrieveCalls += 1;\n if (match[1] === 'miss') misses += 1;\n else if (match[1] === 'hit') hits.add(JSON.parse(match[2]!) as string);\n }\n\n return { elisionsStored, bytesStored, retrieveCalls, uniqueRetrieved: hits.size, misses };\n }\n\n stats(): RetrieveStats {\n return retrieveStats(this.rawCounters());\n }\n\n /**\n * The per-rule ledger: a fold over the journal's `put` lines against its `hit`\n * lines, derived by the shared `ruleLedger()`. Uncounted, and read off the disk\n * like everything else here, so two processes agree.\n */\n ledger(): readonly RuleLedgerEntry[] {\n const puts: { hash: string; rule: string }[] = [];\n const hits = new Set<string>();\n for (const line of this.#readLog().split('\\n')) {\n const put = PUT_LINE.exec(line);\n if (put !== null) {\n puts.push({ hash: JSON.parse(put[1]!) as string, rule: JSON.parse(put[2]!) as string });\n continue;\n }\n const counter = LOG_LINE.exec(line);\n if (counter !== null && counter[1] === 'hit') hits.add(JSON.parse(counter[2]!) as string);\n }\n return ruleLedger(puts, hits);\n }\n\n /** The blob's exact content, or `undefined` when no such blob is stored. */\n #readBlob(hash: string): string | undefined {\n if (!KEY_PATTERN.test(hash)) return undefined; // never a path component\n try {\n return readFileSync(join(this.#blobsDir, hash), 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n }\n\n /** Write content to a unique file in `tmp/`, fsynced, and return its path. */\n #writeTemp(content: string): string {\n const tmpPath = join(this.#tmpDir, `${String(process.pid)}-${randomBytes(8).toString('hex')}`);\n const fd = openSync(tmpPath, 'wx');\n try {\n // writeSync may write fewer bytes than asked; loop, or a short write would be\n // fsynced and published under the full content's hash as a torn blob.\n const bytes = Buffer.from(content, 'utf8');\n let written = 0;\n while (written < bytes.length) {\n written += writeSync(fd, bytes, written);\n }\n fsyncSync(fd);\n } finally {\n closeSync(fd);\n }\n return tmpPath;\n }\n\n /**\n * A journal append on the `retrieve()` path — counting, not custody. A failure here\n * must never decide whether the caller gets its verified bytes (or its true error),\n * so it is caught and surfaced as a distinct `process.emitWarning` — see the class\n * doc, and the read-only-journal case in `test/store-dir.test.ts`.\n */\n #appendLogCounting(kind: 'hit' | 'miss' | 'corrupt' | 'put', hash: string, rule?: string): void {\n try {\n this.#appendLog(kind, hash, rule);\n } catch (error) {\n process.emitWarning(\n `smelt: could not journal a \"${kind}\" for hash \"${hash}\" in ${this.#logPath} ` +\n `(${error instanceof Error ? error.message : String(error)}). The retrieval ` +\n `itself is unaffected, but this count is lost — retrieveCalls and ` +\n `expansionRate now UNDER-report until the journal is writable again.`,\n 'SmeltCounterWriteFailure',\n );\n }\n }\n\n /**\n * One durable journal line. The hash is JSON-encoded because `retrieve()` takes it\n * from the model verbatim — a hash containing a newline must not forge a second line.\n * The record starts with its own newline so a torn tail from an earlier crash — a\n * partial record with no trailing newline — can never bleed into this one: the tear\n * stays on its own line and is skipped by `stats()`, as blank lines are.\n */\n #appendLog(kind: 'hit' | 'miss' | 'corrupt' | 'put', hash: string, rule?: string): void {\n const fd = openSync(this.#logPath, 'a');\n try {\n const fields =\n rule === undefined\n ? [kind, JSON.stringify(hash)]\n : [kind, JSON.stringify(hash), JSON.stringify(rule)];\n const record = Buffer.from(`\\n${fields.join(' ')}\\n`, 'utf8');\n let written = 0;\n while (written < record.length) {\n written += writeSync(fd, record, written);\n }\n fsyncSync(fd);\n } finally {\n closeSync(fd);\n }\n }\n\n #readLog(): string {\n try {\n return readFileSync(this.#logPath, 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return '';\n throw error;\n }\n }\n\n /**\n * Write the format marker if this directory has none, or verify the one it has.\n * Creation is atomic (write to `tmp/`, then `link`), so a concurrent creator never\n * observes a half-written marker. The constructor pre-verified any pre-existing\n * marker; the verify here catches only a concurrent creator's claim.\n */\n #claimFormat(markerPath: string): void {\n const claim = (): string | undefined => {\n const body = `${JSON.stringify({\n format: DIRECTORY_STORE_FORMAT,\n version: DIRECTORY_STORE_VERSION,\n })}\\n`;\n const tmpPath = this.#writeTemp(body);\n try {\n linkSync(tmpPath, markerPath);\n return undefined; // claimed by us; nothing to verify\n } catch (error) {\n if ((error as { code?: string }).code !== 'EEXIST') throw error;\n return readFileSync(markerPath, 'utf8');\n } finally {\n unlinkSync(tmpPath);\n }\n };\n\n const existing = claim();\n if (existing === undefined) return;\n this.#verifyMarker(markerPath, existing);\n }\n\n /** The marker's body, or `undefined` when the directory carries none. */\n #readMarker(markerPath: string): string | undefined {\n try {\n return readFileSync(markerPath, 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n }\n\n /** Refuse a marker this version of smelt does not understand. */\n #verifyMarker(markerPath: string, existing: string): void {\n let parsed: { format?: unknown; version?: unknown };\n try {\n parsed = JSON.parse(existing) as { format?: unknown; version?: unknown };\n } catch {\n throw new StoreFormatError(\n `smelt: \"${markerPath}\" is not parseable JSON, so this directory cannot be ` +\n `trusted as an elision store. Refusing to read or write it.`,\n );\n }\n if (parsed.format !== DIRECTORY_STORE_FORMAT || parsed.version !== DIRECTORY_STORE_VERSION) {\n throw new StoreFormatError(\n `smelt: \"${markerPath}\" declares format ${JSON.stringify(parsed.format)} ` +\n `version ${JSON.stringify(parsed.version)}; this code understands ` +\n `\"${DIRECTORY_STORE_FORMAT}\" version ${String(DIRECTORY_STORE_VERSION)}. ` +\n `Refusing to reinterpret someone else's layout.`,\n );\n }\n }\n}\n\n/**\n * Flush the directory entry after a publish, so the *name* survives a crash as well as\n * the bytes. Where the platform refuses to fsync a directory (Windows does), the publish\n * is still atomic — only the durability of the directory entry falls back to the OS's\n * own schedule. Only that refusal is swallowed: a real I/O failure (`EIO`) propagates,\n * because \"the disk could not flush\" must never be reported as a successful put.\n *\n * Where it does work it is the same `fsyncSync` the blob's own flush uses, and so is\n * exactly as strong as that — see the durability note on {@link DirectoryElisionStore}\n * for what that means per platform.\n */\nfunction fsyncDirBestEffort(path: string): void {\n let fd: number;\n try {\n fd = openSync(path, 'r');\n } catch {\n return; // the platform refuses to even open a directory for reading (Windows)\n }\n try {\n fsyncSync(fd);\n } catch (error) {\n const code = (error as { code?: string }).code;\n // EINVAL/ENOTSUP/EPERM/EBADF: the platform refuses to fsync a directory — see the\n // doc comment. Anything else (EIO above all) is a genuine write failure.\n if (code !== 'EINVAL' && code !== 'ENOTSUP' && code !== 'EPERM' && code !== 'EBADF') {\n throw error;\n }\n } finally {\n closeSync(fd);\n }\n}\n"]}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RawRetrieveCounters } from './stats.ts';
|
|
2
|
-
import type { ElisionStore, RetrieveStats } from './types.ts';
|
|
2
|
+
import type { ElisionReason, ElisionStore, RetrieveStats, RuleLedgerEntry } from './types.ts';
|
|
3
3
|
/**
|
|
4
4
|
* The default store: in-process, content-addressed, no eviction.
|
|
5
5
|
*
|
|
@@ -25,7 +25,7 @@ export interface MemoryElisionStoreOptions {
|
|
|
25
25
|
export declare class MemoryElisionStore implements ElisionStore {
|
|
26
26
|
#private;
|
|
27
27
|
constructor(options?: MemoryElisionStoreOptions);
|
|
28
|
-
put(content: string): string;
|
|
28
|
+
put(content: string, reason?: ElisionReason): string;
|
|
29
29
|
peek(hash: string): string | undefined;
|
|
30
30
|
retrieve(hash: string): string;
|
|
31
31
|
/**
|
|
@@ -38,5 +38,7 @@ export declare class MemoryElisionStore implements ElisionStore {
|
|
|
38
38
|
/** The five directly-observed counts. See {@link RawRetrieveCounters} — no derivation here. */
|
|
39
39
|
rawCounters(): RawRetrieveCounters;
|
|
40
40
|
stats(): RetrieveStats;
|
|
41
|
+
/** The per-rule ledger, derived by the shared `ruleLedger()` — see {@link RuleLedgerEntry}. */
|
|
42
|
+
ledger(): readonly RuleLedgerEntry[];
|
|
41
43
|
}
|
|
42
44
|
//# sourceMappingURL=store.d.ts.map
|
package/dist/store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAE9F;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,yBAAyB;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;CAC7C;AAED,qBAAa,kBAAmB,YAAW,YAAY;;IAUrD,YAAY,OAAO,GAAE,yBAA8B,EAElD;IAED,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,aAAa,GAAG,MAAM,CAYnD;IAED,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAErC;IAED,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAS7B;IAED;;;;;OAKG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEzB;IAED,+FAA+F;IAC/F,WAAW,IAAI,mBAAmB,CAQjC;IAED,KAAK,IAAI,aAAa,CAErB;IAED,+FAA+F;IAC/F,MAAM,IAAI,SAAS,eAAe,EAAE,CAEnC;CACF","sourcesContent":["import { HashCollisionError, UnknownHashError } from './errors.ts';\nimport { contentHash } from './hash.ts';\nimport { retrieveStats, ruleLedger } from './stats.ts';\nimport type { RawRetrieveCounters } from './stats.ts';\nimport type { ElisionReason, ElisionStore, RetrieveStats, RuleLedgerEntry } from './types.ts';\n\n/**\n * The default store: in-process, content-addressed, no eviction.\n *\n * In-process is the right default because it is the only one with no failure modes to\n * explain. A consumer that needs elisions to survive a process restart (a long-lived\n * agent session, say) hands `createSmelter()` a {@link DirectoryElisionStore} — or\n * implements {@link ElisionStore} over storage of its own; the interface is five\n * methods wide for exactly that reason.\n *\n * There is no `clear()` and no LRU. A store that can forget turns Law 3 into\n * \"reversible, usually\", and a `retrieve()` that fails after an eviction is\n * indistinguishable to the model from a hallucinated hash.\n */\nexport interface MemoryElisionStoreOptions {\n /**\n * Override the hash function. The only reason this exists: the collision branch in\n * {@link MemoryElisionStore.put} is unreachable with sha256, and an untestable branch\n * is a branch nobody knows works. A test injects a colliding hash and watches it\n * throw. Production has no reason to pass this.\n */\n readonly hash?: (content: string) => string;\n}\n\nexport class MemoryElisionStore implements ElisionStore {\n readonly #blobs = new Map<string, string>();\n readonly #hash: (content: string) => string;\n #bytesStored = 0;\n #retrieveCalls = 0;\n #misses = 0;\n readonly #retrievedHashes = new Set<string>();\n /** Every attributed put, in order — the ledger's facts. */\n readonly #puts: { readonly hash: string; readonly rule: string }[] = [];\n\n constructor(options: MemoryElisionStoreOptions = {}) {\n this.#hash = options.hash ?? contentHash;\n }\n\n put(content: string, reason?: ElisionReason): string {\n const hash = this.#hash(content);\n const existing = this.#blobs.get(hash);\n if (existing !== undefined) {\n if (existing !== content) throw new HashCollisionError(hash);\n if (reason !== undefined) this.#puts.push({ hash, rule: reason.rule });\n return hash;\n }\n this.#blobs.set(hash, content);\n this.#bytesStored += Buffer.byteLength(content, 'utf8');\n if (reason !== undefined) this.#puts.push({ hash, rule: reason.rule });\n return hash;\n }\n\n peek(hash: string): string | undefined {\n return this.#blobs.get(hash);\n }\n\n retrieve(hash: string): string {\n this.#retrieveCalls += 1;\n const content = this.#blobs.get(hash);\n if (content === undefined) {\n this.#misses += 1;\n throw new UnknownHashError(hash);\n }\n this.#retrievedHashes.add(hash);\n return content;\n }\n\n /**\n * True exactly when `retrieve(hash)` would return bytes. Nothing to verify: `put`\n * hashed the content itself and no one else can reach the map, so a key here cannot\n * name bytes that stopped matching it — unlike a directory on someone's disk, where\n * {@link DirectoryElisionStore.has} re-hashes for this same promise.\n */\n has(hash: string): boolean {\n return this.#blobs.has(hash);\n }\n\n /** The five directly-observed counts. See {@link RawRetrieveCounters} — no derivation here. */\n rawCounters(): RawRetrieveCounters {\n return {\n elisionsStored: this.#blobs.size,\n bytesStored: this.#bytesStored,\n retrieveCalls: this.#retrieveCalls,\n uniqueRetrieved: this.#retrievedHashes.size,\n misses: this.#misses,\n };\n }\n\n stats(): RetrieveStats {\n return retrieveStats(this.rawCounters());\n }\n\n /** The per-rule ledger, derived by the shared `ruleLedger()` — see {@link RuleLedgerEntry}. */\n ledger(): readonly RuleLedgerEntry[] {\n return ruleLedger(this.#puts, this.#retrievedHashes);\n }\n}\n"]}
|
package/dist/store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HashCollisionError, UnknownHashError } from './errors.js';
|
|
2
2
|
import { contentHash } from './hash.js';
|
|
3
|
-
import { retrieveStats } from './stats.js';
|
|
3
|
+
import { retrieveStats, ruleLedger } from './stats.js';
|
|
4
4
|
export class MemoryElisionStore {
|
|
5
5
|
#blobs = new Map();
|
|
6
6
|
#hash;
|
|
@@ -8,19 +8,25 @@ export class MemoryElisionStore {
|
|
|
8
8
|
#retrieveCalls = 0;
|
|
9
9
|
#misses = 0;
|
|
10
10
|
#retrievedHashes = new Set();
|
|
11
|
+
/** Every attributed put, in order — the ledger's facts. */
|
|
12
|
+
#puts = [];
|
|
11
13
|
constructor(options = {}) {
|
|
12
14
|
this.#hash = options.hash ?? contentHash;
|
|
13
15
|
}
|
|
14
|
-
put(content) {
|
|
16
|
+
put(content, reason) {
|
|
15
17
|
const hash = this.#hash(content);
|
|
16
18
|
const existing = this.#blobs.get(hash);
|
|
17
19
|
if (existing !== undefined) {
|
|
18
20
|
if (existing !== content)
|
|
19
21
|
throw new HashCollisionError(hash);
|
|
22
|
+
if (reason !== undefined)
|
|
23
|
+
this.#puts.push({ hash, rule: reason.rule });
|
|
20
24
|
return hash;
|
|
21
25
|
}
|
|
22
26
|
this.#blobs.set(hash, content);
|
|
23
27
|
this.#bytesStored += Buffer.byteLength(content, 'utf8');
|
|
28
|
+
if (reason !== undefined)
|
|
29
|
+
this.#puts.push({ hash, rule: reason.rule });
|
|
24
30
|
return hash;
|
|
25
31
|
}
|
|
26
32
|
peek(hash) {
|
|
@@ -58,5 +64,9 @@ export class MemoryElisionStore {
|
|
|
58
64
|
stats() {
|
|
59
65
|
return retrieveStats(this.rawCounters());
|
|
60
66
|
}
|
|
67
|
+
/** The per-rule ledger, derived by the shared `ruleLedger()` — see {@link RuleLedgerEntry}. */
|
|
68
|
+
ledger() {
|
|
69
|
+
return ruleLedger(this.#puts, this.#retrievedHashes);
|
|
70
|
+
}
|
|
61
71
|
}
|
|
62
72
|
//# sourceMappingURL=store.js.map
|
package/dist/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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","sourcesContent":["import { HashCollisionError, UnknownHashError } from './errors.ts';\nimport { contentHash } from './hash.ts';\nimport { retrieveStats, ruleLedger } from './stats.ts';\nimport type { RawRetrieveCounters } from './stats.ts';\nimport type { ElisionReason, ElisionStore, RetrieveStats, RuleLedgerEntry } from './types.ts';\n\n/**\n * The default store: in-process, content-addressed, no eviction.\n *\n * In-process is the right default because it is the only one with no failure modes to\n * explain. A consumer that needs elisions to survive a process restart (a long-lived\n * agent session, say) hands `createSmelter()` a {@link DirectoryElisionStore} — or\n * implements {@link ElisionStore} over storage of its own; the interface is five\n * methods wide for exactly that reason.\n *\n * There is no `clear()` and no LRU. A store that can forget turns Law 3 into\n * \"reversible, usually\", and a `retrieve()` that fails after an eviction is\n * indistinguishable to the model from a hallucinated hash.\n */\nexport interface MemoryElisionStoreOptions {\n /**\n * Override the hash function. The only reason this exists: the collision branch in\n * {@link MemoryElisionStore.put} is unreachable with sha256, and an untestable branch\n * is a branch nobody knows works. A test injects a colliding hash and watches it\n * throw. Production has no reason to pass this.\n */\n readonly hash?: (content: string) => string;\n}\n\nexport class MemoryElisionStore implements ElisionStore {\n readonly #blobs = new Map<string, string>();\n readonly #hash: (content: string) => string;\n #bytesStored = 0;\n #retrieveCalls = 0;\n #misses = 0;\n readonly #retrievedHashes = new Set<string>();\n /** Every attributed put, in order — the ledger's facts. */\n readonly #puts: { readonly hash: string; readonly rule: string }[] = [];\n\n constructor(options: MemoryElisionStoreOptions = {}) {\n this.#hash = options.hash ?? contentHash;\n }\n\n put(content: string, reason?: ElisionReason): string {\n const hash = this.#hash(content);\n const existing = this.#blobs.get(hash);\n if (existing !== undefined) {\n if (existing !== content) throw new HashCollisionError(hash);\n if (reason !== undefined) this.#puts.push({ hash, rule: reason.rule });\n return hash;\n }\n this.#blobs.set(hash, content);\n this.#bytesStored += Buffer.byteLength(content, 'utf8');\n if (reason !== undefined) this.#puts.push({ hash, rule: reason.rule });\n return hash;\n }\n\n peek(hash: string): string | undefined {\n return this.#blobs.get(hash);\n }\n\n retrieve(hash: string): string {\n this.#retrieveCalls += 1;\n const content = this.#blobs.get(hash);\n if (content === undefined) {\n this.#misses += 1;\n throw new UnknownHashError(hash);\n }\n this.#retrievedHashes.add(hash);\n return content;\n }\n\n /**\n * True exactly when `retrieve(hash)` would return bytes. Nothing to verify: `put`\n * hashed the content itself and no one else can reach the map, so a key here cannot\n * name bytes that stopped matching it — unlike a directory on someone's disk, where\n * {@link DirectoryElisionStore.has} re-hashes for this same promise.\n */\n has(hash: string): boolean {\n return this.#blobs.has(hash);\n }\n\n /** The five directly-observed counts. See {@link RawRetrieveCounters} — no derivation here. */\n rawCounters(): RawRetrieveCounters {\n return {\n elisionsStored: this.#blobs.size,\n bytesStored: this.#bytesStored,\n retrieveCalls: this.#retrieveCalls,\n uniqueRetrieved: this.#retrievedHashes.size,\n misses: this.#misses,\n };\n }\n\n stats(): RetrieveStats {\n return retrieveStats(this.rawCounters());\n }\n\n /** The per-rule ledger, derived by the shared `ruleLedger()` — see {@link RuleLedgerEntry}. */\n ledger(): readonly RuleLedgerEntry[] {\n return ruleLedger(this.#puts, this.#retrievedHashes);\n }\n}\n"]}
|
package/dist/text/json-edit.d.ts
CHANGED
|
@@ -52,7 +52,21 @@ export declare function jsonStyle(text: string): JsonStyle;
|
|
|
52
52
|
* array, a scalar, or something that is not JSON at all. Callers should have
|
|
53
53
|
* `JSON.parse`d first and refused; this is belt and braces, not a validator.
|
|
54
54
|
*/
|
|
55
|
-
export declare function editTopLevelProperty(text: string, key: string, value: unknown, style?: JsonStyle): string | undefined;
|
|
55
|
+
export declare function editTopLevelProperty(text: string, key: string, value: unknown, style?: JsonStyle, valueIndent?: string): string | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* `editJsonProperty`: the same contract, one level deeper. Replace, insert or remove
|
|
58
|
+
* the property at `path` — e.g. `['mcpServers', 'smelt']` — where the *container* is a
|
|
59
|
+
* top-level property whose value is itself a JSON object. Everything outside the
|
|
60
|
+
* edited bytes rides through verbatim, including sibling entries inside the container.
|
|
61
|
+
*
|
|
62
|
+
* When the container key is absent and a value is given, the container is created
|
|
63
|
+
* fresh around the entry. When a removal empties the container, the container is
|
|
64
|
+
* lifted out too — a file that never carried the key comes back byte-identical after
|
|
65
|
+
* an apply → remove round trip, and one that carried other entries keeps them
|
|
66
|
+
* untouched. Returns `undefined` when the container's value is not a JSON object the
|
|
67
|
+
* scanner can walk (the caller refuses or skips, as with {@link editTopLevelProperty}).
|
|
68
|
+
*/
|
|
69
|
+
export declare function editJsonProperty(text: string, path: readonly [string, ...(readonly string[])], value: unknown, style?: JsonStyle): string | undefined;
|
|
56
70
|
/**
|
|
57
71
|
* Replace the block delimited by `start` … `end` in `existingText`, or append it.
|
|
58
72
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"json-edit.d.ts","sourceRoot":"","sources":["../../src/text/json-edit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,kFAAkF;AAClF,MAAM,WAAW,SAAS;IACxB,oEAAoE;IACpE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,iEAAiE;IACjE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAKjD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,OAAO,EACd,KAAK,GAAE,SAA2B,GACjC,MAAM,GAAG,SAAS,CAWpB;AA+ID;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,GACV,MAAM,CAUR;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,GACV,MAAM,GAAG,SAAS,CAQpB","sourcesContent":["/**\n * Byte-faithful edits to somebody else's text file.\n *\n * Two editors, one contract: **change the one thing you were asked to change and\n * leave every other byte alone.** An installer that reformats a settings file has\n * edited what it was never asked to — key order, indentation, string escapes, number\n * spellings and unknown keys are all somebody's choices, and `JSON.parse` →\n * `JSON.stringify` erases every one of them.\n *\n * - {@link editTopLevelProperty}: replace, insert or remove **one top-level property**\n * of a JSON object, in its source text. The value is rendered fresh; nothing\n * outside its bytes moves. Callers have already `JSON.parse`d the text and decided\n * what the new value is; this module holds only the tokenising.\n * - {@link upsertMarkerBlock} / {@link stripMarkerBlock}: the same idea over a\n * delimited block in a plain-text file (an instruction file, a Markdown snippet\n * between two marker lines).\n *\n * Neither knows what a harness or a hook is. `cli/hooks.ts` is the consumer today; the\n * module lives under `src/text/` rather than `cli/` because its whole interface is\n * strings in, strings out — it reads no argv, prints nothing and imports nothing from\n * the CLI, and the next byte-faithful editor (an instruction-file rewrite) wants a\n * sibling here, not a CLI import.\n */\n\n/** How a JSON file is laid out — what a rendered value must match to blend in. */\nexport interface JsonStyle {\n /** The indentation unit — the whitespace before a top-level key. */\n readonly indent: string;\n /** `'\\r\\n'` when the file uses it anywhere, `'\\n'` otherwise. */\n readonly newline: string;\n}\n\n/**\n * The layout an existing file uses: its first indented key's leading whitespace (two\n * spaces when nothing is indented) and its newline convention. Detected once, before\n * any edit, so a sequence of edits renders consistently even after an earlier one has\n * changed the first indented line.\n */\nexport function jsonStyle(text: string): JsonStyle {\n return {\n newline: text.includes('\\r\\n') ? '\\r\\n' : '\\n',\n indent: /\\n([ \\t]+)\"/.exec(text)?.[1] ?? ' ',\n };\n}\n\n/**\n * Replace, insert or remove one top-level property of the JSON object in `text`,\n * leaving every other byte verbatim.\n *\n * - `value` defined: the property's value is replaced in place when the key exists,\n * otherwise the property is appended after the last one (or into an empty object).\n * - `value === undefined`: the property is removed, with its separating comma and\n * whitespace; a key that is not there is a no-op and the text comes back unchanged.\n *\n * The rendered value is `JSON.stringify(value, null, indent)`, re-indented to sit at\n * top level. `style` defaults to {@link jsonStyle} of `text`; pass it explicitly when\n * making several edits to one file.\n *\n * Returns `undefined` when `text` is not a JSON object the scanner can walk — an\n * array, a scalar, or something that is not JSON at all. Callers should have\n * `JSON.parse`d first and refused; this is belt and braces, not a validator.\n */\nexport function editTopLevelProperty(\n text: string,\n key: string,\n value: unknown,\n style: JsonStyle = jsonStyle(text),\n): string | undefined {\n const scan = scanJsonTopLevel(text);\n if (scan === undefined) return undefined;\n const property = scan.properties.find((candidate) => candidate.key === key);\n if (value === undefined) {\n return property === undefined ? text : removeJsonProperty(text, scan, property);\n }\n const rendered = renderJsonValue(value, style.indent, style.newline);\n return property !== undefined\n ? `${text.slice(0, property.valueStart)}${rendered}${text.slice(property.valueEnd)}`\n : insertJsonProperty(text, scan, key, rendered, style.indent, style.newline);\n}\n\n/** One top-level property of a JSON object, located by offsets in its source text. */\ninterface JsonTopLevelProperty {\n readonly key: string;\n /** Offset of the key's opening quote. */\n readonly keyStart: number;\n /** Offset of the value's first byte. */\n readonly valueStart: number;\n /** Offset one past the value's last byte. */\n readonly valueEnd: number;\n}\n\ninterface JsonTopLevelScan {\n /** Offset of the root object's `{`. */\n readonly open: number;\n /** Offset of the root object's `}`. */\n readonly close: number;\n readonly properties: readonly JsonTopLevelProperty[];\n}\n\n/**\n * Locate the top-level properties of a JSON object *in its source text*, so one\n * property can be replaced, inserted or removed while every other byte of the file\n * rides through verbatim. `undefined` when the text is not an object.\n */\nfunction scanJsonTopLevel(text: string): JsonTopLevelScan | undefined {\n let i = skipJsonWhitespace(text, 0);\n if (text[i] !== '{') return undefined;\n const open = i;\n i = skipJsonWhitespace(text, i + 1);\n const properties: JsonTopLevelProperty[] = [];\n if (text[i] === '}') return { open, close: i, properties };\n for (;;) {\n if (text[i] !== '\"') return undefined;\n const keyStart = i;\n const keyEnd = skipJsonString(text, i);\n if (keyEnd === undefined) return undefined;\n const key = JSON.parse(text.slice(keyStart, keyEnd)) as string;\n i = skipJsonWhitespace(text, keyEnd);\n if (text[i] !== ':') return undefined;\n const valueStart = skipJsonWhitespace(text, i + 1);\n const valueEnd = skipJsonValue(text, valueStart);\n if (valueEnd === undefined) return undefined;\n properties.push({ key, keyStart, valueStart, valueEnd });\n i = skipJsonWhitespace(text, valueEnd);\n if (text[i] === ',') {\n i = skipJsonWhitespace(text, i + 1);\n continue;\n }\n if (text[i] === '}') return { open, close: i, properties };\n return undefined;\n }\n}\n\nfunction skipJsonWhitespace(text: string, from: number): number {\n let i = from;\n while (i < text.length && ' \\t\\r\\n'.includes(text[i]!)) i += 1;\n return i;\n}\n\n/** `from` points at `\"`; returns the offset one past the closing quote. */\nfunction skipJsonString(text: string, from: number): number | undefined {\n let i = from + 1;\n while (i < text.length) {\n if (text[i] === '\\\\') i += 2;\n else if (text[i] === '\"') return i + 1;\n else i += 1;\n }\n return undefined;\n}\n\nfunction skipJsonValue(text: string, from: number): number | undefined {\n const first = text[from];\n if (first === '\"') return skipJsonString(text, from);\n if (first === '{' || first === '[') {\n let depth = 0;\n let i = from;\n while (i < text.length) {\n const ch = text[i]!;\n if (ch === '\"') {\n const end = skipJsonString(text, i);\n if (end === undefined) return undefined;\n i = end;\n continue;\n }\n if (ch === '{' || ch === '[') depth += 1;\n else if (ch === '}' || ch === ']') {\n depth -= 1;\n if (depth === 0) return i + 1;\n }\n i += 1;\n }\n return undefined;\n }\n // number / true / false / null\n let i = from;\n while (i < text.length && !',}] \\t\\r\\n'.includes(text[i]!)) i += 1;\n return i > from ? i : undefined;\n}\n\n/** A JSON value indented for embedding at a top-level property position. */\nfunction renderJsonValue(value: unknown, indent: string, newline: string): string {\n return JSON.stringify(value, null, indent).split('\\n').join(`${newline}${indent}`);\n}\n\nfunction removeJsonProperty(\n text: string,\n scan: JsonTopLevelScan,\n property: JsonTopLevelProperty,\n): string {\n const index = scan.properties.indexOf(property);\n const next = scan.properties[index + 1];\n if (next !== undefined) {\n // Delete through the separating comma and whitespace, up to the next key.\n return text.slice(0, property.keyStart) + text.slice(next.keyStart);\n }\n const previous = scan.properties[index - 1];\n // Last (or only) property: delete the preceding comma (if any) with it.\n const from = previous !== undefined ? previous.valueEnd : scan.open + 1;\n return text.slice(0, from) + text.slice(property.valueEnd);\n}\n\nfunction insertJsonProperty(\n text: string,\n scan: JsonTopLevelScan,\n key: string,\n renderedValue: string,\n indent: string,\n newline: string,\n): string {\n const entry = `${JSON.stringify(key)}: ${renderedValue}`;\n if (scan.properties.length === 0) {\n return `${text.slice(0, scan.open + 1)}${newline}${indent}${entry}${newline}${text.slice(scan.close)}`;\n }\n const last = scan.properties[scan.properties.length - 1]!;\n return `${text.slice(0, last.valueEnd)},${newline}${indent}${entry}${text.slice(last.valueEnd)}`;\n}\n\n/* ------------------------------------------------------------------------------------\n * Marker blocks\n * ---------------------------------------------------------------------------------- */\n\n/**\n * Replace the block delimited by `start` … `end` in `existingText`, or append it.\n *\n * An absent or blank file becomes exactly `block`. A file that carries the block has\n * it replaced in place (one newline after `end` is absorbed, so a block that ends in\n * its own newline does not grow a blank line per re-run). A file without it gets the\n * block appended after exactly one blank line, whatever trailing newlines it had.\n */\nexport function upsertMarkerBlock(\n existingText: string | undefined,\n block: string,\n start: string,\n end: string,\n): string {\n if (existingText === undefined || existingText.trim() === '') return block;\n const startIndex = existingText.indexOf(start);\n const endIndex = existingText.indexOf(end);\n if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {\n const before = existingText.slice(0, startIndex);\n const after = existingText.slice(endIndex + end.length).replace(/^\\n/, '');\n return `${before}${block}${after}`;\n }\n return `${existingText.replace(/\\n*$/, '\\n\\n')}${block}`;\n}\n\n/**\n * Remove the block delimited by `start` … `end`. The text comes back unchanged when\n * the block is not there; `undefined` when nothing (or only whitespace) remains — the\n * file was entirely the block, and the caller decides whether to delete it.\n */\nexport function stripMarkerBlock(\n existingText: string,\n start: string,\n end: string,\n): string | undefined {\n const startIndex = existingText.indexOf(start);\n const endIndex = existingText.indexOf(end);\n if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) return existingText;\n const stripped =\n existingText.slice(0, startIndex).replace(/\\n+$/, '\\n') +\n existingText.slice(endIndex + end.length).replace(/^\\n+/, '');\n return stripped.trim() === '' ? undefined : stripped;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"json-edit.d.ts","sourceRoot":"","sources":["../../src/text/json-edit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,kFAAkF;AAClF,MAAM,WAAW,SAAS;IACxB,oEAAoE;IACpE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,iEAAiE;IACjE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAKjD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,OAAO,EACd,KAAK,GAAE,SAA2B,EAClC,WAAW,GAAE,MAAqB,GACjC,MAAM,GAAG,SAAS,CAWpB;AAuJD;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,SAAS,CAAC,MAAM,EAAE,GAAG,CAAC,SAAS,MAAM,EAAE,CAAC,CAAC,EAC/C,KAAK,EAAE,OAAO,EACd,KAAK,GAAE,SAA2B,GACjC,MAAM,GAAG,SAAS,CAEpB;AA+DD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,GACV,MAAM,CAUR;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,GACV,MAAM,GAAG,SAAS,CAQpB","sourcesContent":["/**\n * Byte-faithful edits to somebody else's text file.\n *\n * Two editors, one contract: **change the one thing you were asked to change and\n * leave every other byte alone.** An installer that reformats a settings file has\n * edited what it was never asked to — key order, indentation, string escapes, number\n * spellings and unknown keys are all somebody's choices, and `JSON.parse` →\n * `JSON.stringify` erases every one of them.\n *\n * - {@link editTopLevelProperty}: replace, insert or remove **one top-level property**\n * of a JSON object, in its source text. The value is rendered fresh; nothing\n * outside its bytes moves. Callers have already `JSON.parse`d the text and decided\n * what the new value is; this module holds only the tokenising.\n * - {@link upsertMarkerBlock} / {@link stripMarkerBlock}: the same idea over a\n * delimited block in a plain-text file (an instruction file, a Markdown snippet\n * between two marker lines).\n *\n * Neither knows what a harness or a hook is. `cli/hooks.ts` is the consumer today; the\n * module lives under `src/text/` rather than `cli/` because its whole interface is\n * strings in, strings out — it reads no argv, prints nothing and imports nothing from\n * the CLI, and the next byte-faithful editor (an instruction-file rewrite) wants a\n * sibling here, not a CLI import.\n */\n\n/** How a JSON file is laid out — what a rendered value must match to blend in. */\nexport interface JsonStyle {\n /** The indentation unit — the whitespace before a top-level key. */\n readonly indent: string;\n /** `'\\r\\n'` when the file uses it anywhere, `'\\n'` otherwise. */\n readonly newline: string;\n}\n\n/**\n * The layout an existing file uses: its first indented key's leading whitespace (two\n * spaces when nothing is indented) and its newline convention. Detected once, before\n * any edit, so a sequence of edits renders consistently even after an earlier one has\n * changed the first indented line.\n */\nexport function jsonStyle(text: string): JsonStyle {\n return {\n newline: text.includes('\\r\\n') ? '\\r\\n' : '\\n',\n indent: /\\n([ \\t]+)\"/.exec(text)?.[1] ?? ' ',\n };\n}\n\n/**\n * Replace, insert or remove one top-level property of the JSON object in `text`,\n * leaving every other byte verbatim.\n *\n * - `value` defined: the property's value is replaced in place when the key exists,\n * otherwise the property is appended after the last one (or into an empty object).\n * - `value === undefined`: the property is removed, with its separating comma and\n * whitespace; a key that is not there is a no-op and the text comes back unchanged.\n *\n * The rendered value is `JSON.stringify(value, null, indent)`, re-indented to sit at\n * top level. `style` defaults to {@link jsonStyle} of `text`; pass it explicitly when\n * making several edits to one file.\n *\n * Returns `undefined` when `text` is not a JSON object the scanner can walk — an\n * array, a scalar, or something that is not JSON at all. Callers should have\n * `JSON.parse`d first and refused; this is belt and braces, not a validator.\n */\nexport function editTopLevelProperty(\n text: string,\n key: string,\n value: unknown,\n style: JsonStyle = jsonStyle(text),\n valueIndent: string = style.indent,\n): string | undefined {\n const scan = scanJsonTopLevel(text);\n if (scan === undefined) return undefined;\n const property = scan.properties.find((candidate) => candidate.key === key);\n if (value === undefined) {\n return property === undefined ? text : removeJsonProperty(text, scan, property);\n }\n const rendered = renderJsonValue(value, valueIndent, style.indent, style.newline);\n return property !== undefined\n ? `${text.slice(0, property.valueStart)}${rendered}${text.slice(property.valueEnd)}`\n : insertJsonProperty(text, scan, key, rendered, style.indent, style.newline);\n}\n\n/** One top-level property of a JSON object, located by offsets in its source text. */\ninterface JsonTopLevelProperty {\n readonly key: string;\n /** Offset of the key's opening quote. */\n readonly keyStart: number;\n /** Offset of the value's first byte. */\n readonly valueStart: number;\n /** Offset one past the value's last byte. */\n readonly valueEnd: number;\n}\n\ninterface JsonTopLevelScan {\n /** Offset of the root object's `{`. */\n readonly open: number;\n /** Offset of the root object's `}`. */\n readonly close: number;\n readonly properties: readonly JsonTopLevelProperty[];\n}\n\n/**\n * Locate the top-level properties of a JSON object *in its source text*, so one\n * property can be replaced, inserted or removed while every other byte of the file\n * rides through verbatim. `undefined` when the text is not an object.\n */\nfunction scanJsonTopLevel(text: string): JsonTopLevelScan | undefined {\n let i = skipJsonWhitespace(text, 0);\n if (text[i] !== '{') return undefined;\n const open = i;\n i = skipJsonWhitespace(text, i + 1);\n const properties: JsonTopLevelProperty[] = [];\n if (text[i] === '}') return { open, close: i, properties };\n for (;;) {\n if (text[i] !== '\"') return undefined;\n const keyStart = i;\n const keyEnd = skipJsonString(text, i);\n if (keyEnd === undefined) return undefined;\n const key = JSON.parse(text.slice(keyStart, keyEnd)) as string;\n i = skipJsonWhitespace(text, keyEnd);\n if (text[i] !== ':') return undefined;\n const valueStart = skipJsonWhitespace(text, i + 1);\n const valueEnd = skipJsonValue(text, valueStart);\n if (valueEnd === undefined) return undefined;\n properties.push({ key, keyStart, valueStart, valueEnd });\n i = skipJsonWhitespace(text, valueEnd);\n if (text[i] === ',') {\n i = skipJsonWhitespace(text, i + 1);\n continue;\n }\n if (text[i] === '}') return { open, close: i, properties };\n return undefined;\n }\n}\n\nfunction skipJsonWhitespace(text: string, from: number): number {\n let i = from;\n while (i < text.length && ' \\t\\r\\n'.includes(text[i]!)) i += 1;\n return i;\n}\n\n/** `from` points at `\"`; returns the offset one past the closing quote. */\nfunction skipJsonString(text: string, from: number): number | undefined {\n let i = from + 1;\n while (i < text.length) {\n if (text[i] === '\\\\') i += 2;\n else if (text[i] === '\"') return i + 1;\n else i += 1;\n }\n return undefined;\n}\n\nfunction skipJsonValue(text: string, from: number): number | undefined {\n const first = text[from];\n if (first === '\"') return skipJsonString(text, from);\n if (first === '{' || first === '[') {\n let depth = 0;\n let i = from;\n while (i < text.length) {\n const ch = text[i]!;\n if (ch === '\"') {\n const end = skipJsonString(text, i);\n if (end === undefined) return undefined;\n i = end;\n continue;\n }\n if (ch === '{' || ch === '[') depth += 1;\n else if (ch === '}' || ch === ']') {\n depth -= 1;\n if (depth === 0) return i + 1;\n }\n i += 1;\n }\n return undefined;\n }\n // number / true / false / null\n let i = from;\n while (i < text.length && !',}] \\t\\r\\n'.includes(text[i]!)) i += 1;\n return i > from ? i : undefined;\n}\n\n/**\n * A JSON value rendered for embedding at a property position. `stringifyIndent` is\n * the unit its own members nest by; `joinIndent` is where the value sits — for a\n * top-level property they are the same, and for a member of a nested container the\n * value sits at the member's indent while its members keep nesting by the file's\n * unit, which is the difference between `command` landing one level under `smelt`\n * or two under it.\n */\nfunction renderJsonValue(\n value: unknown,\n stringifyIndent: string,\n joinIndent: string,\n newline: string,\n): string {\n return JSON.stringify(value, null, stringifyIndent).split('\\n').join(`${newline}${joinIndent}`);\n}\n\nfunction removeJsonProperty(\n text: string,\n scan: JsonTopLevelScan,\n property: JsonTopLevelProperty,\n): string {\n const index = scan.properties.indexOf(property);\n const next = scan.properties[index + 1];\n if (next !== undefined) {\n // Delete through the separating comma and whitespace, up to the next key.\n return text.slice(0, property.keyStart) + text.slice(next.keyStart);\n }\n const previous = scan.properties[index - 1];\n // Last (or only) property: delete the preceding comma (if any) with it.\n const from = previous !== undefined ? previous.valueEnd : scan.open + 1;\n return text.slice(0, from) + text.slice(property.valueEnd);\n}\n\nfunction insertJsonProperty(\n text: string,\n scan: JsonTopLevelScan,\n key: string,\n renderedValue: string,\n indent: string,\n newline: string,\n): string {\n const entry = `${JSON.stringify(key)}: ${renderedValue}`;\n if (scan.properties.length === 0) {\n return `${text.slice(0, scan.open + 1)}${newline}${indent}${entry}${newline}${text.slice(scan.close)}`;\n }\n const last = scan.properties[scan.properties.length - 1]!;\n return `${text.slice(0, last.valueEnd)},${newline}${indent}${entry}${text.slice(last.valueEnd)}`;\n}\n\n/**\n * `editJsonProperty`: the same contract, one level deeper. Replace, insert or remove\n * the property at `path` — e.g. `['mcpServers', 'smelt']` — where the *container* is a\n * top-level property whose value is itself a JSON object. Everything outside the\n * edited bytes rides through verbatim, including sibling entries inside the container.\n *\n * When the container key is absent and a value is given, the container is created\n * fresh around the entry. When a removal empties the container, the container is\n * lifted out too — a file that never carried the key comes back byte-identical after\n * an apply → remove round trip, and one that carried other entries keeps them\n * untouched. Returns `undefined` when the container's value is not a JSON object the\n * scanner can walk (the caller refuses or skips, as with {@link editTopLevelProperty}).\n */\nexport function editJsonProperty(\n text: string,\n path: readonly [string, ...(readonly string[])],\n value: unknown,\n style: JsonStyle = jsonStyle(text),\n): string | undefined {\n return editJsonPropertyAt(text, path, value, style, style.indent);\n}\n\n/** The walker: same body, `path` as a plain (runtime-checked) array. */\nfunction editJsonPropertyAt(\n text: string,\n path: readonly string[],\n value: unknown,\n style: JsonStyle,\n baseIndent: string,\n): string | undefined {\n const head = path[0]!;\n const rest = path.slice(1);\n if (rest.length === 0) return editTopLevelProperty(text, head, value, style, baseIndent);\n const scan = scanJsonTopLevel(text);\n if (scan === undefined) return undefined;\n const property = scan.properties.find((candidate) => candidate.key === head);\n if (property === undefined) {\n if (value === undefined) return text; // nothing to remove under an absent container\n // Build the fresh container from the tail of the path, then hand it to the\n // top-level editor as a plain value — one renderer, one indent story.\n const container: Record<string, unknown> = {};\n let cursor = container;\n for (const key of rest.slice(0, -1)) {\n const next: Record<string, unknown> = {};\n cursor[key] = next;\n cursor = next;\n }\n cursor[rest[rest.length - 1]!] = value;\n return editTopLevelProperty(text, head, container, style);\n }\n const inner = text.slice(property.valueStart, property.valueEnd);\n // The container's members sit one unit deeper than the file's top level — read\n // that unit off the container's own first key, so a fresh member lands beside its\n // siblings and the member's *value* keeps nesting by the file's unit (passed as\n // valueIndent), which is what one level deeper actually means.\n const memberIndent = /\\n([ \\t]+)\"/.exec(inner)?.[1] ?? style.indent + style.indent;\n const edited = editJsonPropertyAt(\n inner,\n rest,\n value,\n { ...style, indent: memberIndent },\n baseIndent,\n );\n if (edited === undefined) return undefined; // the container's value is not an object\n if (edited === inner) return text;\n if (value === undefined && removesToEmptyObject(edited)) {\n // The container is now `{}` and it only got that way because of this removal —\n // lift it out, so a file that never carried the key round-trips byte-identical.\n return editTopLevelProperty(text, head, undefined, style) ?? text;\n }\n return `${text.slice(0, property.valueStart)}${edited}${text.slice(property.valueEnd)}`;\n}\n\n/** True when `text` is exactly a JSON object with no properties. */\nfunction removesToEmptyObject(text: string): boolean {\n const scan = scanJsonTopLevel(text);\n return scan !== undefined && scan.properties.length === 0;\n}\n\n/* ------------------------------------------------------------------------------------\n * Marker blocks\n * ---------------------------------------------------------------------------------- */\n\n/**\n * Replace the block delimited by `start` … `end` in `existingText`, or append it.\n *\n * An absent or blank file becomes exactly `block`. A file that carries the block has\n * it replaced in place (one newline after `end` is absorbed, so a block that ends in\n * its own newline does not grow a blank line per re-run). A file without it gets the\n * block appended after exactly one blank line, whatever trailing newlines it had.\n */\nexport function upsertMarkerBlock(\n existingText: string | undefined,\n block: string,\n start: string,\n end: string,\n): string {\n if (existingText === undefined || existingText.trim() === '') return block;\n const startIndex = existingText.indexOf(start);\n const endIndex = existingText.indexOf(end);\n if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {\n const before = existingText.slice(0, startIndex);\n const after = existingText.slice(endIndex + end.length).replace(/^\\n/, '');\n return `${before}${block}${after}`;\n }\n return `${existingText.replace(/\\n*$/, '\\n\\n')}${block}`;\n}\n\n/**\n * Remove the block delimited by `start` … `end`. The text comes back unchanged when\n * the block is not there; `undefined` when nothing (or only whitespace) remains — the\n * file was entirely the block, and the caller decides whether to delete it.\n */\nexport function stripMarkerBlock(\n existingText: string,\n start: string,\n end: string,\n): string | undefined {\n const startIndex = existingText.indexOf(start);\n const endIndex = existingText.indexOf(end);\n if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) return existingText;\n const stripped =\n existingText.slice(0, startIndex).replace(/\\n+$/, '\\n') +\n existingText.slice(endIndex + end.length).replace(/^\\n+/, '');\n return stripped.trim() === '' ? undefined : stripped;\n}\n"]}
|
package/dist/text/json-edit.js
CHANGED
|
@@ -50,7 +50,7 @@ export function jsonStyle(text) {
|
|
|
50
50
|
* array, a scalar, or something that is not JSON at all. Callers should have
|
|
51
51
|
* `JSON.parse`d first and refused; this is belt and braces, not a validator.
|
|
52
52
|
*/
|
|
53
|
-
export function editTopLevelProperty(text, key, value, style = jsonStyle(text)) {
|
|
53
|
+
export function editTopLevelProperty(text, key, value, style = jsonStyle(text), valueIndent = style.indent) {
|
|
54
54
|
const scan = scanJsonTopLevel(text);
|
|
55
55
|
if (scan === undefined)
|
|
56
56
|
return undefined;
|
|
@@ -58,7 +58,7 @@ export function editTopLevelProperty(text, key, value, style = jsonStyle(text))
|
|
|
58
58
|
if (value === undefined) {
|
|
59
59
|
return property === undefined ? text : removeJsonProperty(text, scan, property);
|
|
60
60
|
}
|
|
61
|
-
const rendered = renderJsonValue(value, style.indent, style.newline);
|
|
61
|
+
const rendered = renderJsonValue(value, valueIndent, style.indent, style.newline);
|
|
62
62
|
return property !== undefined
|
|
63
63
|
? `${text.slice(0, property.valueStart)}${rendered}${text.slice(property.valueEnd)}`
|
|
64
64
|
: insertJsonProperty(text, scan, key, rendered, style.indent, style.newline);
|
|
@@ -155,9 +155,16 @@ function skipJsonValue(text, from) {
|
|
|
155
155
|
i += 1;
|
|
156
156
|
return i > from ? i : undefined;
|
|
157
157
|
}
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
/**
|
|
159
|
+
* A JSON value rendered for embedding at a property position. `stringifyIndent` is
|
|
160
|
+
* the unit its own members nest by; `joinIndent` is where the value sits — for a
|
|
161
|
+
* top-level property they are the same, and for a member of a nested container the
|
|
162
|
+
* value sits at the member's indent while its members keep nesting by the file's
|
|
163
|
+
* unit, which is the difference between `command` landing one level under `smelt`
|
|
164
|
+
* or two under it.
|
|
165
|
+
*/
|
|
166
|
+
function renderJsonValue(value, stringifyIndent, joinIndent, newline) {
|
|
167
|
+
return JSON.stringify(value, null, stringifyIndent).split('\n').join(`${newline}${joinIndent}`);
|
|
161
168
|
}
|
|
162
169
|
function removeJsonProperty(text, scan, property) {
|
|
163
170
|
const index = scan.properties.indexOf(property);
|
|
@@ -179,6 +186,70 @@ function insertJsonProperty(text, scan, key, renderedValue, indent, newline) {
|
|
|
179
186
|
const last = scan.properties[scan.properties.length - 1];
|
|
180
187
|
return `${text.slice(0, last.valueEnd)},${newline}${indent}${entry}${text.slice(last.valueEnd)}`;
|
|
181
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* `editJsonProperty`: the same contract, one level deeper. Replace, insert or remove
|
|
191
|
+
* the property at `path` — e.g. `['mcpServers', 'smelt']` — where the *container* is a
|
|
192
|
+
* top-level property whose value is itself a JSON object. Everything outside the
|
|
193
|
+
* edited bytes rides through verbatim, including sibling entries inside the container.
|
|
194
|
+
*
|
|
195
|
+
* When the container key is absent and a value is given, the container is created
|
|
196
|
+
* fresh around the entry. When a removal empties the container, the container is
|
|
197
|
+
* lifted out too — a file that never carried the key comes back byte-identical after
|
|
198
|
+
* an apply → remove round trip, and one that carried other entries keeps them
|
|
199
|
+
* untouched. Returns `undefined` when the container's value is not a JSON object the
|
|
200
|
+
* scanner can walk (the caller refuses or skips, as with {@link editTopLevelProperty}).
|
|
201
|
+
*/
|
|
202
|
+
export function editJsonProperty(text, path, value, style = jsonStyle(text)) {
|
|
203
|
+
return editJsonPropertyAt(text, path, value, style, style.indent);
|
|
204
|
+
}
|
|
205
|
+
/** The walker: same body, `path` as a plain (runtime-checked) array. */
|
|
206
|
+
function editJsonPropertyAt(text, path, value, style, baseIndent) {
|
|
207
|
+
const head = path[0];
|
|
208
|
+
const rest = path.slice(1);
|
|
209
|
+
if (rest.length === 0)
|
|
210
|
+
return editTopLevelProperty(text, head, value, style, baseIndent);
|
|
211
|
+
const scan = scanJsonTopLevel(text);
|
|
212
|
+
if (scan === undefined)
|
|
213
|
+
return undefined;
|
|
214
|
+
const property = scan.properties.find((candidate) => candidate.key === head);
|
|
215
|
+
if (property === undefined) {
|
|
216
|
+
if (value === undefined)
|
|
217
|
+
return text; // nothing to remove under an absent container
|
|
218
|
+
// Build the fresh container from the tail of the path, then hand it to the
|
|
219
|
+
// top-level editor as a plain value — one renderer, one indent story.
|
|
220
|
+
const container = {};
|
|
221
|
+
let cursor = container;
|
|
222
|
+
for (const key of rest.slice(0, -1)) {
|
|
223
|
+
const next = {};
|
|
224
|
+
cursor[key] = next;
|
|
225
|
+
cursor = next;
|
|
226
|
+
}
|
|
227
|
+
cursor[rest[rest.length - 1]] = value;
|
|
228
|
+
return editTopLevelProperty(text, head, container, style);
|
|
229
|
+
}
|
|
230
|
+
const inner = text.slice(property.valueStart, property.valueEnd);
|
|
231
|
+
// The container's members sit one unit deeper than the file's top level — read
|
|
232
|
+
// that unit off the container's own first key, so a fresh member lands beside its
|
|
233
|
+
// siblings and the member's *value* keeps nesting by the file's unit (passed as
|
|
234
|
+
// valueIndent), which is what one level deeper actually means.
|
|
235
|
+
const memberIndent = /\n([ \t]+)"/.exec(inner)?.[1] ?? style.indent + style.indent;
|
|
236
|
+
const edited = editJsonPropertyAt(inner, rest, value, { ...style, indent: memberIndent }, baseIndent);
|
|
237
|
+
if (edited === undefined)
|
|
238
|
+
return undefined; // the container's value is not an object
|
|
239
|
+
if (edited === inner)
|
|
240
|
+
return text;
|
|
241
|
+
if (value === undefined && removesToEmptyObject(edited)) {
|
|
242
|
+
// The container is now `{}` and it only got that way because of this removal —
|
|
243
|
+
// lift it out, so a file that never carried the key round-trips byte-identical.
|
|
244
|
+
return editTopLevelProperty(text, head, undefined, style) ?? text;
|
|
245
|
+
}
|
|
246
|
+
return `${text.slice(0, property.valueStart)}${edited}${text.slice(property.valueEnd)}`;
|
|
247
|
+
}
|
|
248
|
+
/** True when `text` is exactly a JSON object with no properties. */
|
|
249
|
+
function removesToEmptyObject(text) {
|
|
250
|
+
const scan = scanJsonTopLevel(text);
|
|
251
|
+
return scan !== undefined && scan.properties.length === 0;
|
|
252
|
+
}
|
|
182
253
|
/* ------------------------------------------------------------------------------------
|
|
183
254
|
* Marker blocks
|
|
184
255
|
* ---------------------------------------------------------------------------------- */
|