knodin 0.13.0 → 0.14.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/bin/cli.js +120 -9
- package/dist/src/backup-retention.js +345 -12
- package/dist/src/class-consumer-cursor.js +2 -0
- package/dist/src/cli-model.js +12 -0
- package/dist/src/compact-structural.js +7 -2
- package/dist/src/engine/apex-class-uses.js +54 -8
- package/dist/src/engine/apex-dead-chains.js +368 -0
- package/dist/src/engine/apex-entry-points.js +44 -8
- package/dist/src/engine/candidate-database.js +624 -66
- package/dist/src/engine/index.js +1768 -258
- package/dist/src/engine/seal.js +28 -4
- package/dist/src/engine/state-paths.js +192 -31
- package/dist/src/init.js +14 -2
- package/dist/src/manager-update.js +9 -10
- package/dist/src/mirror.js +2 -0
- package/dist/src/response-budget.js +14 -2
- package/dist/src/shared-index/restore.js +16 -6
- package/dist/src/storage-budget.js +146 -0
- package/dist/src/storage-inventory.js +263 -0
- package/dist/src/storage-management.js +204 -0
- package/dist/src/storage-policy-contract.js +121 -0
- package/dist/src/tools/knodin-tools.js +1 -0
- package/dist/src/worktree-seed.js +35 -2
- package/docs/BACKUP-RETENTION.md +46 -2
- package/docs/CLI.md +12 -5
- package/docs/DEAD-CODE-AND-IMPACT.md +41 -0
- package/docs/MCP.md +15 -3
- package/docs/SALESFORCE-BINDINGS.md +26 -8
- package/docs/releases/0.13.1.md +216 -0
- package/docs/releases/0.14.0.md +78 -0
- package/package.json +4 -1
package/dist/src/engine/seal.js
CHANGED
|
@@ -140,14 +140,38 @@ export function readSealAttestation(db) {
|
|
|
140
140
|
* which is exactly the silent false negative the consumer needs to be able to
|
|
141
141
|
* detect in order to fall back.
|
|
142
142
|
*/
|
|
143
|
-
export function readSealedSource(db, filePath) {
|
|
143
|
+
export function readSealedSource(db, filePath, maxBytes) {
|
|
144
|
+
if (maxBytes !== undefined) {
|
|
145
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
146
|
+
throw new RangeError("Invalid sealed source byte limit");
|
|
147
|
+
const bounded = db
|
|
148
|
+
.query("SELECT s.bytes, s.compressed, s.rawBytes, s.storedBytes, s.sha256 FROM sealed_file f JOIN sealed_source s ON s.sha256=f.sha256 WHERE f.filePath=? AND length(s.bytes)<=?")
|
|
149
|
+
.get(filePath, maxBytes);
|
|
150
|
+
if (!bounded)
|
|
151
|
+
return null;
|
|
152
|
+
if (![0, 1].includes(bounded.compressed) ||
|
|
153
|
+
!Number.isSafeInteger(bounded.rawBytes) ||
|
|
154
|
+
bounded.rawBytes < 0 ||
|
|
155
|
+
bounded.rawBytes > maxBytes ||
|
|
156
|
+
bounded.storedBytes !== bounded.bytes.byteLength)
|
|
157
|
+
throw new Error("Invalid bounded sealed source metadata");
|
|
158
|
+
const raw = bounded.compressed
|
|
159
|
+
? zlib.zstdDecompressSync(bounded.bytes, { maxOutputLength: maxBytes })
|
|
160
|
+
: Buffer.from(bounded.bytes);
|
|
161
|
+
if (raw.byteLength !== bounded.rawBytes ||
|
|
162
|
+
crypto.createHash("sha256").update(raw).digest("hex") !== bounded.sha256)
|
|
163
|
+
throw new Error("Sealed source integrity mismatch");
|
|
164
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw);
|
|
165
|
+
}
|
|
144
166
|
const row = db
|
|
145
|
-
.query("SELECT s.bytes bytes, s.compressed compressed FROM sealed_file f JOIN sealed_source s ON s.sha256 = f.sha256 WHERE f.filePath = ?")
|
|
146
|
-
.get(filePath);
|
|
167
|
+
.query("SELECT s.bytes bytes, s.compressed compressed FROM sealed_file f JOIN sealed_source s ON s.sha256 = f.sha256 WHERE f.filePath = ? AND length(s.bytes) <= ?")
|
|
168
|
+
.get(filePath, maxBytes ?? Number.MAX_SAFE_INTEGER);
|
|
147
169
|
if (!row)
|
|
148
170
|
return null;
|
|
149
171
|
const raw = Buffer.from(row.bytes);
|
|
150
|
-
return (row.compressed
|
|
172
|
+
return (row.compressed
|
|
173
|
+
? zlib.zstdDecompressSync(raw, maxBytes === undefined ? undefined : { maxOutputLength: maxBytes })
|
|
174
|
+
: raw).toString("utf8");
|
|
151
175
|
}
|
|
152
176
|
/**
|
|
153
177
|
* Every path the sealed artifact carries source for.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -32,6 +33,28 @@ export const MIRROR_STATE_DIR = "state";
|
|
|
32
33
|
/** In-tree state directory name for a normal checkout. */
|
|
33
34
|
export const IN_TREE_STATE_DIR = ".knodin";
|
|
34
35
|
const REGISTRY_VERSION = 1;
|
|
36
|
+
const MIRROR_IDENTITY = /^repo_[0-9a-f]{20}$/;
|
|
37
|
+
function assertMirrorIdentity(identity) {
|
|
38
|
+
if (!MIRROR_IDENTITY.test(identity))
|
|
39
|
+
throw new Error("mirror registry: invalid repository identity");
|
|
40
|
+
}
|
|
41
|
+
function containedMirrorDirectory(parent, name) {
|
|
42
|
+
const target = path.join(parent, name);
|
|
43
|
+
for (const directory of [parent, target]) {
|
|
44
|
+
try {
|
|
45
|
+
const stat = fs.lstatSync(directory);
|
|
46
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
47
|
+
throw new Error("mirror registry: unsafe state directory");
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== "ENOENT")
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (fs.existsSync(target) && path.dirname(fs.realpathSync(target)) !== fs.realpathSync(parent))
|
|
55
|
+
throw new Error("mirror registry: escaped state directory");
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
35
58
|
const EMPTY_REGISTRY = { version: REGISTRY_VERSION, mirrors: [] };
|
|
36
59
|
/**
|
|
37
60
|
* Root for all mirror storage. Defaults to `~/.knodin/mirrors` rather than an
|
|
@@ -49,19 +72,20 @@ export function mirrorRegistryPath() {
|
|
|
49
72
|
}
|
|
50
73
|
/** Root for in-progress clones, renamed into place only once complete. */
|
|
51
74
|
export function mirrorStagingRoot() {
|
|
52
|
-
return
|
|
75
|
+
return containedMirrorDirectory(mirrorRoot(), ".staging");
|
|
53
76
|
}
|
|
54
77
|
/** Entry root for one mirror: the parent of both `source/` and `state/`. */
|
|
55
78
|
export function mirrorEntryPath(identity) {
|
|
56
|
-
|
|
79
|
+
assertMirrorIdentity(identity);
|
|
80
|
+
return containedMirrorDirectory(mirrorRoot(), identity);
|
|
57
81
|
}
|
|
58
82
|
/** Absolute path to a mirror's shallow clone. */
|
|
59
83
|
export function mirrorSourcePath(identity) {
|
|
60
|
-
return
|
|
84
|
+
return containedMirrorDirectory(mirrorEntryPath(identity), MIRROR_SOURCE_DIR);
|
|
61
85
|
}
|
|
62
86
|
/** Absolute path to a mirror's graph state directory. */
|
|
63
87
|
export function mirrorStatePath(identity) {
|
|
64
|
-
return
|
|
88
|
+
return containedMirrorDirectory(mirrorEntryPath(identity), MIRROR_STATE_DIR);
|
|
65
89
|
}
|
|
66
90
|
let cache = null;
|
|
67
91
|
function isMirrorRecord(value) {
|
|
@@ -69,10 +93,13 @@ function isMirrorRecord(value) {
|
|
|
69
93
|
return false;
|
|
70
94
|
const record = value;
|
|
71
95
|
return (typeof record.identity === "string" &&
|
|
96
|
+
MIRROR_IDENTITY.test(record.identity) &&
|
|
72
97
|
typeof record.url === "string" &&
|
|
73
98
|
typeof record.path === "string" &&
|
|
74
99
|
typeof record.fetchedAt === "string" &&
|
|
75
|
-
typeof record.sha === "string"
|
|
100
|
+
typeof record.sha === "string" &&
|
|
101
|
+
record.readOnly === true &&
|
|
102
|
+
path.isAbsolute(record.path));
|
|
76
103
|
}
|
|
77
104
|
function indexByPath(registry) {
|
|
78
105
|
const byPath = new Map();
|
|
@@ -80,57 +107,172 @@ function indexByPath(registry) {
|
|
|
80
107
|
byPath.set(path.resolve(record.path), record);
|
|
81
108
|
return byPath;
|
|
82
109
|
}
|
|
110
|
+
function assertMirrorRecordPath(record) {
|
|
111
|
+
const expected = mirrorSourcePath(record.identity);
|
|
112
|
+
try {
|
|
113
|
+
if (fs.realpathSync(record.path) !== fs.realpathSync(expected))
|
|
114
|
+
throw new Error("mirror registry: clone path does not match repository identity");
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error.code !== "ENOENT")
|
|
118
|
+
throw error;
|
|
119
|
+
// A missing clone remains registered for actionable recovery, but only at
|
|
120
|
+
// its deterministic managed location. A missing alias cannot be verified.
|
|
121
|
+
if (path.resolve(record.path) !== path.resolve(expected))
|
|
122
|
+
throw new Error("mirror registry: missing clone path does not match repository identity");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
83
125
|
/**
|
|
84
126
|
* Reads the mirror registry, memoized against the file's mtime and size so a
|
|
85
127
|
* mirror added by a concurrent `knodin remote add` is picked up without paying a
|
|
86
128
|
* JSON parse on every path resolution.
|
|
87
129
|
*
|
|
88
|
-
* The memo
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* writer drops the memo explicitly, and the cost of being wrong is one stale
|
|
93
|
-
* read, not corruption. Recorded so it reads as a decision, not an oversight.
|
|
130
|
+
* The metadata memo includes inode/device, ctime, mtime and size. Registry reads
|
|
131
|
+
* use a bounded no-follow descriptor and verify file identity before caching.
|
|
132
|
+
* This is substitution detection, not atomic isolation from hostile filesystem
|
|
133
|
+
* writers; every derived mirror-state path also checks directory containment.
|
|
94
134
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* in-tree state — the pre-existing behaviour — rather than failing a query
|
|
98
|
-
* outright.
|
|
135
|
+
* Only a genuinely absent registry reads as empty. Corrupt, unsafe or unreadable
|
|
136
|
+
* registries fail closed: fallback could redirect a mirror into its source tree.
|
|
99
137
|
*/
|
|
100
138
|
export function readMirrorRegistry() {
|
|
101
139
|
const file = mirrorRegistryPath();
|
|
102
140
|
let stat;
|
|
103
141
|
try {
|
|
104
|
-
stat = fs.
|
|
142
|
+
stat = fs.lstatSync(file);
|
|
105
143
|
}
|
|
106
|
-
catch {
|
|
144
|
+
catch (error) {
|
|
145
|
+
cache = null;
|
|
146
|
+
if (error.code === "ENOENT")
|
|
147
|
+
return EMPTY_REGISTRY;
|
|
148
|
+
throw new Error("mirror registry: unavailable registry", { cause: error });
|
|
149
|
+
}
|
|
150
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_048_576) {
|
|
107
151
|
cache = null;
|
|
108
|
-
|
|
152
|
+
throw new Error("mirror registry: unsafe or oversized registry");
|
|
109
153
|
}
|
|
110
|
-
if (cache?.
|
|
154
|
+
if (cache?.device === stat.dev &&
|
|
155
|
+
cache.inode === stat.ino &&
|
|
156
|
+
cache.ctimeMs === stat.ctimeMs &&
|
|
157
|
+
cache.mtimeMs === stat.mtimeMs &&
|
|
158
|
+
cache.size === stat.size)
|
|
111
159
|
return cache.registry;
|
|
112
160
|
try {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
161
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
const opened = fs.fstatSync(descriptor);
|
|
165
|
+
if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size)
|
|
166
|
+
throw new Error("registry changed during open");
|
|
167
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
168
|
+
const count = fs.readSync(descriptor, bytes, 0, bytes.length, 0);
|
|
169
|
+
const final = fs.fstatSync(descriptor);
|
|
170
|
+
const current = fs.lstatSync(file);
|
|
171
|
+
if (count !== stat.size ||
|
|
172
|
+
final.size !== stat.size ||
|
|
173
|
+
final.ctimeMs !== stat.ctimeMs ||
|
|
174
|
+
current.dev !== stat.dev ||
|
|
175
|
+
current.ino !== stat.ino ||
|
|
176
|
+
current.ctimeMs !== stat.ctimeMs)
|
|
177
|
+
throw new Error("registry changed during read");
|
|
178
|
+
parsed = JSON.parse(bytes.subarray(0, count).toString("utf8"));
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
fs.closeSync(descriptor);
|
|
182
|
+
}
|
|
183
|
+
if (!parsed ||
|
|
184
|
+
typeof parsed !== "object" ||
|
|
185
|
+
parsed.version !== REGISTRY_VERSION ||
|
|
186
|
+
!Array.isArray(parsed.mirrors) ||
|
|
187
|
+
!parsed.mirrors.every(isMirrorRecord))
|
|
188
|
+
throw new Error("invalid mirror registry records");
|
|
189
|
+
const mirrors = parsed.mirrors;
|
|
190
|
+
if (new Set(mirrors.map((record) => record.identity)).size !== mirrors.length)
|
|
191
|
+
throw new Error("duplicate mirror identity");
|
|
192
|
+
for (const record of mirrors) {
|
|
193
|
+
mirrorStatePath(record.identity);
|
|
194
|
+
assertMirrorRecordPath(record);
|
|
195
|
+
}
|
|
196
|
+
const canonicalPaths = mirrors.map((record) => fs.existsSync(record.path) ? fs.realpathSync(record.path) : path.resolve(record.path));
|
|
197
|
+
if (new Set(canonicalPaths).size !== canonicalPaths.length)
|
|
198
|
+
throw new Error("duplicate mirror clone path");
|
|
119
199
|
const registry = { version: REGISTRY_VERSION, mirrors };
|
|
120
|
-
cache = {
|
|
200
|
+
cache = {
|
|
201
|
+
device: stat.dev,
|
|
202
|
+
inode: stat.ino,
|
|
203
|
+
ctimeMs: stat.ctimeMs,
|
|
204
|
+
mtimeMs: stat.mtimeMs,
|
|
205
|
+
size: stat.size,
|
|
206
|
+
registry,
|
|
207
|
+
byPath: indexByPath(registry),
|
|
208
|
+
};
|
|
121
209
|
return registry;
|
|
122
210
|
}
|
|
123
|
-
catch {
|
|
211
|
+
catch (error) {
|
|
124
212
|
cache = null;
|
|
125
|
-
|
|
213
|
+
throw new Error("mirror registry: invalid or unsafe registry; restore it before graph access", {
|
|
214
|
+
cause: error,
|
|
215
|
+
});
|
|
126
216
|
}
|
|
127
217
|
}
|
|
128
218
|
/** Persists the registry and drops the memo so the next read reloads it. */
|
|
129
219
|
export function writeMirrorRegistry(registry) {
|
|
220
|
+
if (!registry.mirrors.every(isMirrorRecord))
|
|
221
|
+
throw new Error("mirror registry: invalid records");
|
|
222
|
+
for (const record of registry.mirrors) {
|
|
223
|
+
mirrorStatePath(record.identity);
|
|
224
|
+
assertMirrorRecordPath(record);
|
|
225
|
+
}
|
|
130
226
|
const file = mirrorRegistryPath();
|
|
131
|
-
|
|
227
|
+
const parent = path.dirname(file);
|
|
228
|
+
containedMirrorDirectory(parent, ".staging");
|
|
229
|
+
fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
230
|
+
const parentIdentity = fs.lstatSync(parent);
|
|
231
|
+
const canonicalParent = fs.realpathSync(parent);
|
|
232
|
+
const verifyDestination = () => {
|
|
233
|
+
const current = fs.lstatSync(parent);
|
|
234
|
+
if (!current.isDirectory() ||
|
|
235
|
+
current.isSymbolicLink() ||
|
|
236
|
+
current.dev !== parentIdentity.dev ||
|
|
237
|
+
current.ino !== parentIdentity.ino ||
|
|
238
|
+
fs.realpathSync(parent) !== canonicalParent)
|
|
239
|
+
throw new Error("mirror registry: parent identity changed");
|
|
240
|
+
try {
|
|
241
|
+
const target = fs.lstatSync(file);
|
|
242
|
+
if (!target.isFile() || target.isSymbolicLink())
|
|
243
|
+
throw new Error("mirror registry: unsafe registry destination");
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (error.code !== "ENOENT")
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
verifyDestination();
|
|
132
251
|
const document = { version: REGISTRY_VERSION, mirrors: registry.mirrors };
|
|
133
|
-
|
|
252
|
+
const bytes = Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
|
|
253
|
+
if (bytes.length > 1_048_576)
|
|
254
|
+
throw new Error("mirror registry: oversized registry");
|
|
255
|
+
const temporary = path.join(parent, `.registry-${process.pid}-${crypto.randomUUID()}.tmp`);
|
|
256
|
+
try {
|
|
257
|
+
const descriptor = fs.openSync(temporary, "wx", 0o600);
|
|
258
|
+
try {
|
|
259
|
+
fs.writeFileSync(descriptor, bytes);
|
|
260
|
+
fs.fsyncSync(descriptor);
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
fs.closeSync(descriptor);
|
|
264
|
+
}
|
|
265
|
+
verifyDestination();
|
|
266
|
+
fs.renameSync(temporary, file);
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
// Only remove the exclusive temporary name while its parent is unchanged.
|
|
270
|
+
const current = fs.lstatSync(parent);
|
|
271
|
+
if (current.dev === parentIdentity.dev &&
|
|
272
|
+
current.ino === parentIdentity.ino &&
|
|
273
|
+
!current.isSymbolicLink())
|
|
274
|
+
fs.rmSync(temporary, { force: true });
|
|
275
|
+
}
|
|
134
276
|
cache = null;
|
|
135
277
|
}
|
|
136
278
|
/** Clears the in-process registry memo. Test seam. */
|
|
@@ -144,7 +286,26 @@ export function __resetMirrorRegistryCache() {
|
|
|
144
286
|
*/
|
|
145
287
|
export function lookupMirror(repoPath) {
|
|
146
288
|
readMirrorRegistry();
|
|
147
|
-
|
|
289
|
+
const exact = cache?.byPath.get(path.resolve(repoPath));
|
|
290
|
+
if (exact)
|
|
291
|
+
return exact;
|
|
292
|
+
let canonical;
|
|
293
|
+
try {
|
|
294
|
+
canonical = fs.realpathSync(repoPath);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
for (const record of cache?.byPath.values() ?? []) {
|
|
300
|
+
try {
|
|
301
|
+
if (fs.realpathSync(record.path) === canonical)
|
|
302
|
+
return record;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
/* Missing clones do not match. */
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
148
309
|
}
|
|
149
310
|
/** True when `repoPath` is a registered read-only mirror clone. */
|
|
150
311
|
export function isMirror(repoPath) {
|
package/dist/src/init.js
CHANGED
|
@@ -27,7 +27,8 @@ export class InitializationHealthError extends Error {
|
|
|
27
27
|
const condition = indexResult.verification.issueCount > 0
|
|
28
28
|
? `${indexResult.verification.issueCount.toLocaleString()} graph issue(s) remain`
|
|
29
29
|
: `graph verification reported status "${indexResult.verification.status}"`;
|
|
30
|
-
|
|
30
|
+
const remedy = indexResult.verification.repairSteps?.[0] ?? "Run `knodin repair`.";
|
|
31
|
+
super(`knodin init: lifecycle hooks were installed, but ${condition}.${detail} ${remedy}`);
|
|
31
32
|
this.name = "InitializationHealthError";
|
|
32
33
|
this.indexResult = indexResult;
|
|
33
34
|
}
|
|
@@ -543,6 +544,13 @@ while :; do
|
|
|
543
544
|
KIND="$(sed -n '1p' "$EVENT")"
|
|
544
545
|
ARG1="$(sed -n '2p' "$EVENT")"
|
|
545
546
|
ARG2="$(sed -n '3p' "$EVENT")"
|
|
547
|
+
# Hand the event sequence to the CLI so its JSON record carries it. indexer.log
|
|
548
|
+
# stays one JSON object per line (pack-install smoke and diagnostics parse it
|
|
549
|
+
# that way); the record's own "at"/"event" fields tie a refresh back to the Git
|
|
550
|
+
# event that queued it. Without that, a refresh that "succeeded" while the
|
|
551
|
+
# graph stayed 33 commits behind could not be correlated (KNODIN-49).
|
|
552
|
+
KNODIN_HOOK_EVENT_SEQUENCE="$(sed -n 's/^sequence=//p' "$EVENT" | head -n 1)"
|
|
553
|
+
export KNODIN_HOOK_EVENT_SEQUENCE
|
|
546
554
|
if [ "$KIND" = "commit" ]; then
|
|
547
555
|
${invocation} --repo "$REPO_ROOT" hook-refresh commit --json >> "$LOG_PATH" 2>> "$LOG_PATH.stderr"
|
|
548
556
|
elif [ "$KIND" = "checkout" ] || [ "$KIND" = "merge" ]; then
|
|
@@ -558,8 +566,12 @@ while :; do
|
|
|
558
566
|
cp "$EVENT" "$LAST_EVENT_PATH"
|
|
559
567
|
rm -f "$EVENT" "$FAILURE_PATH"
|
|
560
568
|
else
|
|
569
|
+
# The CLI's --json error envelope already landed on the log line; name the
|
|
570
|
+
# cause in the failure file too so knodin status can say what broke instead
|
|
571
|
+
# of only that something did.
|
|
572
|
+
DETAIL="$(tail -n 1 "$LOG_PATH.stderr" 2>/dev/null)"
|
|
561
573
|
{
|
|
562
|
-
printf 'knodin background refresh failed with exit code %s
|
|
574
|
+
printf 'knodin background refresh failed at %s with exit code %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$STATUS" "$DETAIL"
|
|
563
575
|
printf 'Pending event retained at %s.\n' "$EVENT"
|
|
564
576
|
printf 'See .knodin/indexer.log for details.\n'
|
|
565
577
|
} > "$FAILURE_TMP"
|
|
@@ -328,6 +328,13 @@ async function boundedChannelJson(url, options) {
|
|
|
328
328
|
clearTimeout(timeout);
|
|
329
329
|
}
|
|
330
330
|
}
|
|
331
|
+
function compatibilityBreaking(value) {
|
|
332
|
+
if (value === "breaking")
|
|
333
|
+
return true;
|
|
334
|
+
if (value === "compatible" || value === "compatible-with-additions" || value === "non-breaking")
|
|
335
|
+
return false;
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
331
338
|
function npmReleases(value) {
|
|
332
339
|
const versions = value.versions;
|
|
333
340
|
const times = value.time;
|
|
@@ -341,11 +348,7 @@ function npmReleases(value) {
|
|
|
341
348
|
version,
|
|
342
349
|
publishedAt: times[version] ?? "",
|
|
343
350
|
yanked: manifest.deprecated === true || typeof manifest.deprecated === "string",
|
|
344
|
-
breaking: policy?.compatibility
|
|
345
|
-
? false
|
|
346
|
-
: policy?.compatibility === "breaking"
|
|
347
|
-
? true
|
|
348
|
-
: null,
|
|
351
|
+
breaking: compatibilityBreaking(policy?.compatibility),
|
|
349
352
|
channel: "npm",
|
|
350
353
|
};
|
|
351
354
|
})
|
|
@@ -362,11 +365,7 @@ function homebrewReleases(value) {
|
|
|
362
365
|
version: String(entry.version),
|
|
363
366
|
publishedAt: typeof entry.publishedAt === "string" ? entry.publishedAt : "",
|
|
364
367
|
yanked: entry.yanked === true,
|
|
365
|
-
breaking: entry.compatibility
|
|
366
|
-
? false
|
|
367
|
-
: entry.compatibility === "breaking"
|
|
368
|
-
? true
|
|
369
|
-
: null,
|
|
368
|
+
breaking: compatibilityBreaking(entry.compatibility),
|
|
370
369
|
channel: "homebrew",
|
|
371
370
|
}))
|
|
372
371
|
.sort((left, right) => compareVersion(right.version, left.version));
|
package/dist/src/mirror.js
CHANGED
|
@@ -278,6 +278,8 @@ export function refreshMirror(identity) {
|
|
|
278
278
|
const source = record.path;
|
|
279
279
|
if (!fs.existsSync(source))
|
|
280
280
|
throw new MirrorError("unknown", `knodin remote: mirror ${identity} is registered but its clone is missing; remove and re-add it`);
|
|
281
|
+
if (fs.realpathSync(source) !== fs.realpathSync(mirrorSourcePath(identity)))
|
|
282
|
+
throw new MirrorError("unknown", "knodin remote: registered clone is outside its managed source directory; restore the registry before refresh");
|
|
281
283
|
runGit(["fetch", "--depth", "1", "origin"], record.url, source);
|
|
282
284
|
runGit(["reset", "--hard", "FETCH_HEAD"], record.url, source);
|
|
283
285
|
const sha = runGit(["rev-parse", "HEAD"], record.url, source).trim();
|
|
@@ -36,6 +36,9 @@ function collectStrings(value, path = "$") {
|
|
|
36
36
|
}
|
|
37
37
|
const PROTECTED_CONTRACT_KEYS = new Set([
|
|
38
38
|
"identity",
|
|
39
|
+
"chainIdentity",
|
|
40
|
+
"candidateReason",
|
|
41
|
+
"partialScope",
|
|
39
42
|
"symbol",
|
|
40
43
|
"file",
|
|
41
44
|
"filePath",
|
|
@@ -70,7 +73,10 @@ function coveragePreviews(value) {
|
|
|
70
73
|
return found;
|
|
71
74
|
}
|
|
72
75
|
function isTruncatableDetail(entry) {
|
|
73
|
-
return (!entry.path.startsWith("$/
|
|
76
|
+
return (!entry.path.startsWith("$/platformEntry/") &&
|
|
77
|
+
!entry.path.startsWith("$/deadCodeAnalysis/rootReachability/") &&
|
|
78
|
+
!entry.path.startsWith("$/deadCodeAnalysis/platformEntries/") &&
|
|
79
|
+
!entry.path.startsWith("$/flowAnalysis/limitations/") &&
|
|
74
80
|
!entry.path.startsWith("$/sharedIndex/") &&
|
|
75
81
|
!COVERAGE_SCALAR_PATH.test(entry.path) &&
|
|
76
82
|
!IMPACT_CONTRACT_PATH.test(entry.path) &&
|
|
@@ -154,6 +160,9 @@ function truncatePayloadToBytes(root, byteLimit) {
|
|
|
154
160
|
const candidate = collectArrays(root)
|
|
155
161
|
.filter((entry) => !entry.path.startsWith("$/responseBudget") &&
|
|
156
162
|
!PROTECTED_CONTRACT_ARRAY_PATHS.has(entry.path) &&
|
|
163
|
+
!entry.path.startsWith("$/platformEntry/") &&
|
|
164
|
+
!entry.path.startsWith("$/deadCodeAnalysis/rootReachability/") &&
|
|
165
|
+
!entry.path.startsWith("$/deadCodeAnalysis/platformEntries/") &&
|
|
157
166
|
entry.value.length > 0)
|
|
158
167
|
.sort((a, b) => bytes(b.value) - bytes(a.value))[0];
|
|
159
168
|
if (!candidate)
|
|
@@ -273,7 +282,10 @@ export function applyResponseBudget(value, operation, request, defaults) {
|
|
|
273
282
|
for (const entry of reportedTotals)
|
|
274
283
|
totals[entry.path] = entry.value.length;
|
|
275
284
|
for (const entry of initialArrays) {
|
|
276
|
-
if (entry.
|
|
285
|
+
if (!entry.path.startsWith("$/platformEntry/") &&
|
|
286
|
+
!entry.path.startsWith("$/deadCodeAnalysis/rootReachability/") &&
|
|
287
|
+
!entry.path.startsWith("$/deadCodeAnalysis/platformEntries/") &&
|
|
288
|
+
entry.value.length > itemLimit) {
|
|
277
289
|
entry.value.splice(itemLimit);
|
|
278
290
|
truncated = true;
|
|
279
291
|
}
|
|
@@ -195,15 +195,25 @@ export async function restoreSharedIndex(options) {
|
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
197
|
finally {
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
try {
|
|
199
|
+
lease?.release();
|
|
200
|
+
if (candidate && !promoted) {
|
|
201
|
+
try {
|
|
202
|
+
await options.engine.discardCandidate(candidate);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Candidate cleanup never justifies touching the active graph.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
200
210
|
try {
|
|
201
|
-
|
|
211
|
+
lifecycleLease?.release();
|
|
202
212
|
}
|
|
203
|
-
|
|
204
|
-
|
|
213
|
+
finally {
|
|
214
|
+
if (promoted)
|
|
215
|
+
options.engine.finalizePromotedStorage?.(options.repo);
|
|
205
216
|
}
|
|
206
217
|
}
|
|
207
|
-
lifecycleLease?.release();
|
|
208
218
|
}
|
|
209
219
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/** Additional free space kept beyond scratch: 512 MiB, including for empty graphs. */
|
|
4
|
+
export const DEFAULT_STORAGE_RESERVE_BYTES = 512 * 1024 * 1024;
|
|
5
|
+
/** Conservative scratch allowance; allocation is not a promise of reclaimable APFS extents. */
|
|
6
|
+
export const DEFAULT_STORAGE_SCRATCH_FACTOR = 2;
|
|
7
|
+
export class StorageBudgetError extends Error {
|
|
8
|
+
constructor(message, options) {
|
|
9
|
+
super(`Storage capacity preflight refused: ${message}. Free disk space or resolve the measurement error, then retry.`, options);
|
|
10
|
+
this.name = "StorageBudgetError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function safeBytes(value, label) {
|
|
14
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
15
|
+
throw new StorageBudgetError(`${label} is unknown or outside safe integer bounds`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function stateRoot(directory) {
|
|
19
|
+
const stat = fs.lstatSync(directory);
|
|
20
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
21
|
+
throw new StorageBudgetError("state directory must be a regular nonsymlink directory");
|
|
22
|
+
return fs.realpathSync(directory);
|
|
23
|
+
}
|
|
24
|
+
/** At most 32 ancestor metadata probes; never creates a directory to measure capacity. */
|
|
25
|
+
function capacityDirectory(directory) {
|
|
26
|
+
let current = path.resolve(directory);
|
|
27
|
+
for (let attempts = 0; attempts < 32; attempts++) {
|
|
28
|
+
try {
|
|
29
|
+
// The state directory itself must not be a symlink. For a new state,
|
|
30
|
+
// existing ancestors may be repository aliases: capacity is measured
|
|
31
|
+
// on their canonical filesystem without creating or trusting state.
|
|
32
|
+
const root = stateRoot(attempts === 0 ? current : fs.realpathSync(current));
|
|
33
|
+
return root;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code !== "ENOENT")
|
|
37
|
+
throw error;
|
|
38
|
+
const parent = path.dirname(current);
|
|
39
|
+
if (parent === current)
|
|
40
|
+
break;
|
|
41
|
+
current = parent;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw new StorageBudgetError("no safe existing capacity directory within 32 ancestors");
|
|
45
|
+
}
|
|
46
|
+
/** Bounded metadata-only measurement of exactly DB, WAL and SHM; missing members are allowed. */
|
|
47
|
+
export function measureDatabaseFamily(stateDirectory, databasePath) {
|
|
48
|
+
try {
|
|
49
|
+
let root;
|
|
50
|
+
try {
|
|
51
|
+
root = stateRoot(stateDirectory);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code !== "ENOENT")
|
|
55
|
+
throw error;
|
|
56
|
+
capacityDirectory(stateDirectory);
|
|
57
|
+
if (path.dirname(path.resolve(databasePath)) !== path.resolve(stateDirectory))
|
|
58
|
+
throw new StorageBudgetError("database family is outside its state directory");
|
|
59
|
+
return { apparentBytes: 0, allocatedBytes: 0, fileCount: 0 };
|
|
60
|
+
}
|
|
61
|
+
const target = path.resolve(databasePath);
|
|
62
|
+
if (fs.realpathSync(path.dirname(target)) !== root)
|
|
63
|
+
throw new StorageBudgetError("database family is outside its state directory");
|
|
64
|
+
let apparentBytes = 0;
|
|
65
|
+
let allocatedBytes = 0;
|
|
66
|
+
let fileCount = 0;
|
|
67
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
68
|
+
const member = `${target}${suffix}`;
|
|
69
|
+
let stat;
|
|
70
|
+
try {
|
|
71
|
+
stat = fs.lstatSync(member);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code === "ENOENT")
|
|
75
|
+
continue;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (!stat.isFile() || stat.isSymbolicLink() || path.dirname(fs.realpathSync(member)) !== root)
|
|
79
|
+
throw new StorageBudgetError("database family must contain only contained regular nonsymlink files");
|
|
80
|
+
apparentBytes = safeBytes(apparentBytes + safeBytes(stat.size, "file size"), "family size");
|
|
81
|
+
if (typeof stat.blocks !== "number" || !Number.isSafeInteger(stat.blocks) || stat.blocks < 0)
|
|
82
|
+
allocatedBytes = null;
|
|
83
|
+
else if (allocatedBytes !== null)
|
|
84
|
+
allocatedBytes = safeBytes(allocatedBytes + safeBytes(stat.blocks * 512, "allocated file size"), "allocated family size");
|
|
85
|
+
fileCount++;
|
|
86
|
+
}
|
|
87
|
+
return { apparentBytes, allocatedBytes, fileCount };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof StorageBudgetError)
|
|
91
|
+
throw error;
|
|
92
|
+
throw new StorageBudgetError("cannot measure database family", { cause: error });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** bavail reflects blocks usable by this process, unlike privileged free-block totals. */
|
|
96
|
+
export function measureAvailableBytes(stateDirectory, statfs = (directory) => fs.statfsSync(directory, { bigint: true })) {
|
|
97
|
+
try {
|
|
98
|
+
const result = statfs(capacityDirectory(stateDirectory));
|
|
99
|
+
if (typeof result.bavail !== "bigint" ||
|
|
100
|
+
typeof result.bsize !== "bigint" ||
|
|
101
|
+
result.bavail < 0n ||
|
|
102
|
+
result.bsize <= 0n)
|
|
103
|
+
throw new StorageBudgetError("filesystem available capacity is unknown");
|
|
104
|
+
const bytes = result.bavail * result.bsize;
|
|
105
|
+
if (bytes > BigInt(Number.MAX_SAFE_INTEGER))
|
|
106
|
+
throw new StorageBudgetError("filesystem capacity exceeds safe integer bounds");
|
|
107
|
+
return Number(bytes);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error instanceof StorageBudgetError)
|
|
111
|
+
throw error;
|
|
112
|
+
throw new StorageBudgetError("filesystem free-space measurement failed or is unsupported", {
|
|
113
|
+
cause: error,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Read-only preflight, not a reservation: callers must still handle ENOSPC and races. */
|
|
118
|
+
export function assertStorageCapacity(options, dependencies = {}) {
|
|
119
|
+
try {
|
|
120
|
+
const reserveBytes = safeBytes(options.reserveBytes ?? DEFAULT_STORAGE_RESERVE_BYTES, "reserve bytes");
|
|
121
|
+
const scratchFactor = options.scratchFactor ?? DEFAULT_STORAGE_SCRATCH_FACTOR;
|
|
122
|
+
if (!Number.isSafeInteger(scratchFactor) || scratchFactor < 1)
|
|
123
|
+
throw new StorageBudgetError("scratch factor must be a positive safe integer");
|
|
124
|
+
const family = (dependencies.measureFamily ?? measureDatabaseFamily)(options.stateDirectory, options.databasePath);
|
|
125
|
+
if (family.allocatedBytes === null)
|
|
126
|
+
throw new StorageBudgetError("allocated family size is unknown on this filesystem");
|
|
127
|
+
const base = Math.max(safeBytes(family.apparentBytes, "family size"), safeBytes(family.allocatedBytes, "allocated family size"));
|
|
128
|
+
const requiredBytes = safeBytes(safeBytes(base * scratchFactor, "scratch bytes") + reserveBytes, "required bytes");
|
|
129
|
+
const availableBytes = safeBytes((dependencies.measureFree ?? measureAvailableBytes)(options.stateDirectory), "available bytes");
|
|
130
|
+
if (availableBytes < requiredBytes)
|
|
131
|
+
throw new StorageBudgetError(`${options.operation} requires ${requiredBytes} additional bytes but only ${availableBytes} bytes are available`);
|
|
132
|
+
return {
|
|
133
|
+
operation: options.operation,
|
|
134
|
+
family,
|
|
135
|
+
availableBytes,
|
|
136
|
+
requiredBytes,
|
|
137
|
+
reserveBytes,
|
|
138
|
+
scratchFactor,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error instanceof StorageBudgetError)
|
|
143
|
+
throw error;
|
|
144
|
+
throw new StorageBudgetError("capacity measurement failed", { cause: error });
|
|
145
|
+
}
|
|
146
|
+
}
|