@codyswann/lisa 4.37.2 → 4.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/all/copy-contents/.gitattributes +9 -0
  2. package/all/copy-contents/gitignore +6 -0
  3. package/dist/core/learnings-merge-driver.d.ts +8 -0
  4. package/dist/core/learnings-merge-driver.d.ts.map +1 -1
  5. package/dist/core/learnings-merge-driver.js +17 -0
  6. package/dist/core/learnings-merge-driver.js.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +4 -0
  9. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  10. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  11. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  12. package/dist/core/upstream-evidence-manifest.js +64 -11
  13. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  14. package/package.json +6 -4
  15. package/plugins/lisa/.claude-plugin/plugin.json +28 -1
  16. package/plugins/lisa/.codex-plugin/hooks.json +27 -0
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +11 -0
  19. package/plugins/lisa/.codex-plugin/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  20. package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +11 -0
  21. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +30 -2
  22. package/plugins/lisa/hooks/inject-resolved-config.mjs +26 -7
  23. package/plugins/lisa/hooks/withdrawn-rulings.mjs +525 -0
  24. package/plugins/lisa/hooks/withdrawn-rulings.sh +24 -0
  25. package/plugins/lisa/rules/eager/config-resolution.md +3 -1
  26. package/plugins/lisa/rules/eager/withdrawn-rulings.md +69 -0
  27. package/plugins/lisa/rules/reference/config-resolution.md +1 -1
  28. package/plugins/lisa/rules/reference/withdrawn-rulings.md +114 -0
  29. package/plugins/lisa/scripts/intake-blocker-reprobe.mjs +120 -3
  30. package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +11 -0
  31. package/plugins/lisa/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  32. package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +11 -0
  33. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +30 -2
  34. package/plugins/lisa-agy/plugin.json +1 -1
  35. package/plugins/lisa-agy/scripts/intake-blocker-reprobe.mjs +120 -3
  36. package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +11 -0
  37. package/plugins/lisa-agy/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  38. package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +11 -0
  39. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +30 -2
  40. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  43. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-copilot/.claude-plugin/plugin.json +19 -1
  46. package/plugins/lisa-copilot/hooks/inject-resolved-config.mjs +26 -7
  47. package/plugins/lisa-copilot/hooks/withdrawn-rulings.mjs +525 -0
  48. package/plugins/lisa-copilot/hooks/withdrawn-rulings.sh +24 -0
  49. package/plugins/lisa-copilot/rules/eager/config-resolution.md +3 -1
  50. package/plugins/lisa-copilot/rules/eager/withdrawn-rulings.md +69 -0
  51. package/plugins/lisa-copilot/rules/reference/config-resolution.md +1 -1
  52. package/plugins/lisa-copilot/rules/reference/withdrawn-rulings.md +114 -0
  53. package/plugins/lisa-copilot/scripts/intake-blocker-reprobe.mjs +120 -3
  54. package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +11 -0
  55. package/plugins/lisa-copilot/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  56. package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +11 -0
  57. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +30 -2
  58. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-cursor/hooks/hooks.json +10 -0
  60. package/plugins/lisa-cursor/hooks/inject-resolved-config.mjs +26 -7
  61. package/plugins/lisa-cursor/hooks/withdrawn-rulings.mjs +525 -0
  62. package/plugins/lisa-cursor/hooks/withdrawn-rulings.sh +24 -0
  63. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +1 -1
  64. package/plugins/lisa-cursor/rules/config-resolution.mdc +3 -1
  65. package/plugins/lisa-cursor/rules/withdrawn-rulings-reference.mdc +119 -0
  66. package/plugins/lisa-cursor/rules/withdrawn-rulings.mdc +74 -0
  67. package/plugins/lisa-cursor/scripts/intake-blocker-reprobe.mjs +120 -3
  68. package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +11 -0
  69. package/plugins/lisa-cursor/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  70. package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +11 -0
  71. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +30 -2
  72. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-expo-agy/plugin.json +1 -1
  75. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  80. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  85. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  90. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  94. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  95. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  99. package/plugins/lisa-rails-agy/plugin.json +1 -1
  100. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  104. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  105. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  109. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  110. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  112. package/plugins/src/base/.claude-plugin/plugin.json +27 -0
  113. package/plugins/src/base/hooks/inject-resolved-config.mjs +26 -7
  114. package/plugins/src/base/hooks/withdrawn-rulings.mjs +525 -0
  115. package/plugins/src/base/hooks/withdrawn-rulings.sh +24 -0
  116. package/plugins/src/base/rules/eager/config-resolution.md +3 -1
  117. package/plugins/src/base/rules/eager/withdrawn-rulings.md +69 -0
  118. package/plugins/src/base/rules/reference/config-resolution.md +1 -1
  119. package/plugins/src/base/rules/reference/withdrawn-rulings.md +114 -0
  120. package/plugins/src/base/scripts/intake-blocker-reprobe.mjs +120 -3
  121. package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +11 -0
  122. package/plugins/src/base/skills/lisa-jira-write-ticket/SKILL.md +11 -0
  123. package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +11 -0
  124. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +30 -2
  125. package/scripts/lib/hook-registration-audit.mjs +612 -0
  126. package/scripts/measure-tmpdir-growth.mjs +925 -0
  127. package/typescript/copy-contents/.husky/pre-push +98 -26
@@ -0,0 +1,925 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Bounded two-snapshot measurement of platform temp-directory growth.
4
+ *
5
+ * A total cannot reveal rate, and a net delta can hide equal creation and
6
+ * removal. This command persists the latest two complete name sets, reports
7
+ * exact created/removed/unreclaimed counts and entries/day, and fails when a
8
+ * newly-created direct CDK/Lisa entry or unowned Lisa namespace child appears.
9
+ * @module scripts/measure-tmpdir-growth
10
+ */
11
+ import { spawnSync } from "node:child_process";
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+ import { isDeepStrictEqual } from "node:util";
16
+
17
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
18
+
19
+ export const TMPDIR_GROWTH_SCHEMA_VERSION = 1;
20
+ export const TMPDIR_GROUPING_VERSION = "mkdtemp-prefix-v1";
21
+ /** Default local evidence path, exported so ignored/untracked policy is testable. */
22
+ export const DEFAULT_TMPDIR_GROWTH_ARTIFACT = ".lisa/tmpdir-growth.json";
23
+ export const MAX_TMPDIR_ENTRIES = 200_000;
24
+ export const MAX_TMPDIR_NAME_BYTES = 1_024;
25
+ export const MAX_NAMESPACE_ENTRIES = 120_000;
26
+ export const MAX_UNIQUE_PREFIXES = 100_000;
27
+ export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
28
+
29
+ const SCRATCH_NAMESPACE = "lisa-scratch";
30
+ const OWNER_FILE = ".lisa-scratch-owner.json";
31
+ const MAX_OWNER_MARKER_BYTES = 16 * 1024;
32
+ const MAX_OWNER_TEXT_BYTES = 256;
33
+ const MAX_OWNER_PREFIXES = 64;
34
+ const DARWIN_BIRTH_BATCH_SIZE = 256;
35
+ const MAX_NAMESPACE_STABILITY_ATTEMPTS = 3;
36
+
37
+ /** Deterministic code-point ordering, independent of locale. */
38
+ const codePointCompare = (left, right) =>
39
+ left === right ? 0 : left < right ? -1 : 1;
40
+
41
+ /**
42
+ * Canonicalise a direct temp basename by stripping mkdtemp's six-character
43
+ * suffix. `lisa-scratch` is a durable singleton namespace and is preserved.
44
+ * @param {string} name Direct basename
45
+ * @returns {string} Prefix label
46
+ */
47
+ export function canonicalizeTmpPrefix(name) {
48
+ if (name === SCRATCH_NAMESPACE) return name;
49
+ return /[A-Za-z0-9]{6}$/u.test(name) ? `${name.slice(0, -6)}*` : name;
50
+ }
51
+
52
+ /**
53
+ * Consume an iterable into a bounded, deterministically sorted name set.
54
+ * @param {Iterable<string>} iterable Entry names
55
+ * @param {number} [limit] Maximum entries
56
+ * @returns {string[]} Sorted names
57
+ */
58
+ export function collectBoundedEntryNames(iterable, limit = MAX_TMPDIR_ENTRIES) {
59
+ const names = [];
60
+ for (const name of iterable) {
61
+ if (typeof name !== "string")
62
+ throw new Error("Temp entry name is not text");
63
+ if (Buffer.byteLength(name, "utf8") > MAX_TMPDIR_NAME_BYTES) {
64
+ throw new Error(
65
+ `Temp entry name exceeds ${String(MAX_TMPDIR_NAME_BYTES)} bytes`
66
+ );
67
+ }
68
+ if (names.length >= limit) {
69
+ throw new Error(`Temp scan exceeds bounded entry limit ${String(limit)}`);
70
+ }
71
+ names.push(name);
72
+ }
73
+ return names.sort(codePointCompare);
74
+ }
75
+
76
+ /** Build sorted prefix counts while enforcing a unique-prefix cap. */
77
+ function prefixCounts(names) {
78
+ const counts = new Map();
79
+ for (const name of names) {
80
+ const prefix = canonicalizeTmpPrefix(name);
81
+ if (!counts.has(prefix) && counts.size >= MAX_UNIQUE_PREFIXES) {
82
+ throw new Error(
83
+ `Temp scan exceeds unique-prefix limit ${String(MAX_UNIQUE_PREFIXES)}`
84
+ );
85
+ }
86
+ counts.set(prefix, (counts.get(prefix) ?? 0) + 1);
87
+ }
88
+ return [...counts.entries()]
89
+ .map(([prefix, count]) => ({ prefix, count }))
90
+ .sort(
91
+ (left, right) =>
92
+ right.count - left.count || codePointCompare(left.prefix, right.prefix)
93
+ );
94
+ }
95
+
96
+ /** Read one platform directory through streaming opendir iteration. */
97
+ function scanDirectNames(root, limit) {
98
+ const directory = fs.opendirSync(root);
99
+ try {
100
+ return collectBoundedEntryNames(
101
+ (function* entries() {
102
+ let entry;
103
+ while ((entry = directory.readSync()) !== null) yield entry.name;
104
+ })(),
105
+ limit
106
+ );
107
+ } finally {
108
+ directory.closeSync();
109
+ }
110
+ }
111
+
112
+ /** Cheap pid liveness probe; EPERM still means alive. */
113
+ function isProcessAlive(pid) {
114
+ try {
115
+ process.kill(pid, 0);
116
+ return true;
117
+ } catch (error) {
118
+ return error.code === "EPERM";
119
+ }
120
+ }
121
+
122
+ /** Run one bounded macOS pid/start-time batch. */
123
+ function runDarwinBirthBatch(pids) {
124
+ const result = spawnSync(
125
+ "ps",
126
+ ["-p", pids.join(","), "-o", "pid=", "-o", "lstart="],
127
+ {
128
+ encoding: "utf8",
129
+ killSignal: "SIGKILL",
130
+ maxBuffer: Math.max(4_096, pids.length * 128),
131
+ timeout: 1_000,
132
+ }
133
+ );
134
+ return result.status === 0 && result.signal === null && !result.error
135
+ ? result.stdout
136
+ : undefined;
137
+ }
138
+
139
+ /**
140
+ * Capture one bounded process-birth snapshot for a complete namespace scan.
141
+ * @param {readonly number[]} pids Process ids to inspect
142
+ * @param {{platform?: NodeJS.Platform, runDarwinBatch?: (pids: readonly number[]) => string | undefined}} [options] Deterministic platform seams
143
+ * @returns {ReadonlyMap<number, string | undefined>} Birth lookup
144
+ */
145
+ export function processBirthFingerprintSnapshot(pids, options = {}) {
146
+ const unique = [...new Set(pids)]
147
+ .filter(pid => Number.isSafeInteger(pid) && pid > 0)
148
+ .sort((left, right) => left - right);
149
+ const snapshot = new Map(unique.map(pid => [pid, undefined]));
150
+ const platform = options.platform ?? process.platform;
151
+ if (platform === "linux") {
152
+ for (const pid of unique) {
153
+ try {
154
+ const stat = fs.readFileSync(`/proc/${String(pid)}/stat`, "utf8");
155
+ const end = stat.lastIndexOf(")");
156
+ const start = stat
157
+ .slice(end + 2)
158
+ .trim()
159
+ .split(/\s+/u)[19];
160
+ snapshot.set(pid, start === undefined ? undefined : `linux:${start}`);
161
+ } catch {
162
+ snapshot.set(pid, undefined);
163
+ }
164
+ }
165
+ return snapshot;
166
+ }
167
+ if (platform !== "darwin") return snapshot;
168
+ const runBatch = options.runDarwinBatch ?? runDarwinBirthBatch;
169
+ for (
170
+ let offset = 0;
171
+ offset < unique.length;
172
+ offset += DARWIN_BIRTH_BATCH_SIZE
173
+ ) {
174
+ const batch = unique.slice(offset, offset + DARWIN_BIRTH_BATCH_SIZE);
175
+ const output = runBatch(batch);
176
+ if (output === undefined) continue;
177
+ for (const row of output.split("\n")) {
178
+ const match = /^\s*(\d+)\s+(.+?)\s*$/u.exec(row);
179
+ const pid = Number(match?.[1]);
180
+ if (!Number.isSafeInteger(pid) || !snapshot.has(pid)) continue;
181
+ const start = match?.[2]?.replace(/\s+/gu, " ") ?? "";
182
+ if (start !== "") snapshot.set(pid, `darwin:${start}`);
183
+ }
184
+ }
185
+ return snapshot;
186
+ }
187
+
188
+ /** Whether an owner string is non-empty and bounded. */
189
+ function isBoundedOwnerText(value) {
190
+ return (
191
+ typeof value === "string" &&
192
+ value !== "" &&
193
+ Buffer.byteLength(value, "utf8") <= MAX_OWNER_TEXT_BYTES
194
+ );
195
+ }
196
+
197
+ /** Whether one parsed identity has bounded canonical and integer fields. */
198
+ function isOwnerIdentity(value) {
199
+ return (
200
+ value !== null &&
201
+ typeof value === "object" &&
202
+ isBoundedOwnerText(value.canonicalPath) &&
203
+ path.isAbsolute(value.canonicalPath) &&
204
+ Number.isSafeInteger(value.dev) &&
205
+ Number.isSafeInteger(value.ino)
206
+ );
207
+ }
208
+
209
+ /** Whether one parsed marker has the complete bounded version-one shape. */
210
+ function isOwnerShape(value) {
211
+ return (
212
+ value !== null &&
213
+ typeof value === "object" &&
214
+ value.schema === 1 &&
215
+ Number.isSafeInteger(value.pid) &&
216
+ value.pid > 0 &&
217
+ isBoundedOwnerText(value.processBirthFingerprint) &&
218
+ isBoundedOwnerText(value.createdAt) &&
219
+ !Number.isNaN(Date.parse(value.createdAt)) &&
220
+ isBoundedOwnerText(value.suiteLabel) &&
221
+ isBoundedOwnerText(value.token) &&
222
+ Array.isArray(value.registeredPrefixes) &&
223
+ value.registeredPrefixes.length <= MAX_OWNER_PREFIXES &&
224
+ value.registeredPrefixes.every(isBoundedOwnerText) &&
225
+ isOwnerIdentity(value.namespace) &&
226
+ isOwnerIdentity(value.root)
227
+ );
228
+ }
229
+
230
+ /** Whether marker authority matches the physical namespace and root inspected. */
231
+ function ownerMatchesPaths(owner, namespace, namespaceStat, root) {
232
+ const rootStat = fs.lstatSync(root);
233
+ return (
234
+ !rootStat.isSymbolicLink() &&
235
+ rootStat.isDirectory() &&
236
+ owner.namespace.canonicalPath === fs.realpathSync(namespace) &&
237
+ owner.namespace.dev === namespaceStat.dev &&
238
+ owner.namespace.ino === namespaceStat.ino &&
239
+ owner.root.canonicalPath === fs.realpathSync(root) &&
240
+ owner.root.dev === rootStat.dev &&
241
+ owner.root.ino === rootStat.ino
242
+ );
243
+ }
244
+
245
+ /** Parse and authority-check an owner marker without mutation. */
246
+ function readOwner(root, namespace, namespaceStat) {
247
+ const marker = path.join(root, OWNER_FILE);
248
+ let stat;
249
+ try {
250
+ stat = fs.lstatSync(marker);
251
+ } catch (error) {
252
+ if (error.code === "ENOENT") return undefined;
253
+ throw error;
254
+ }
255
+ if (
256
+ stat.isSymbolicLink() ||
257
+ !stat.isFile() ||
258
+ stat.size > MAX_OWNER_MARKER_BYTES
259
+ ) {
260
+ return undefined;
261
+ }
262
+ let value;
263
+ try {
264
+ value = JSON.parse(fs.readFileSync(marker, "utf8"));
265
+ } catch (error) {
266
+ if (error instanceof SyntaxError) return undefined;
267
+ throw error;
268
+ }
269
+ return isOwnerShape(value) &&
270
+ ownerMatchesPaths(value, namespace, namespaceStat, root)
271
+ ? value
272
+ : undefined;
273
+ }
274
+
275
+ /** Demand that a namespace still names the exact pinned authority. */
276
+ function assertNamespaceIdentity(namespace, expected) {
277
+ const observed = fs.lstatSync(namespace);
278
+ const uid = process.getuid?.();
279
+ if (
280
+ observed.isSymbolicLink() ||
281
+ !observed.isDirectory() ||
282
+ (uid !== undefined && observed.uid !== uid) ||
283
+ (observed.mode & 0o777) !== 0o700 ||
284
+ observed.dev !== expected.dev ||
285
+ observed.ino !== expected.ino
286
+ ) {
287
+ throw new Error("lisa-scratch namespace identity changed during scan");
288
+ }
289
+ return observed;
290
+ }
291
+
292
+ /** Whether two already-sorted bounded name sets are byte-for-byte equal. */
293
+ function sameNames(left, right) {
294
+ return (
295
+ left.length === right.length &&
296
+ left.every((name, index) => name === right[index])
297
+ );
298
+ }
299
+
300
+ /** Inspect one candidate, distinguishing ordinary absence from other errors. */
301
+ function inspectNamespaceCandidate(name, namespace, namespaceStat, probes) {
302
+ const candidate = path.join(namespace, name);
303
+ try {
304
+ const stat = fs.lstatSync(candidate);
305
+ const owner =
306
+ stat.isDirectory() && !stat.isSymbolicLink()
307
+ ? readOwner(candidate, namespace, namespaceStat)
308
+ : undefined;
309
+ const pidAlive =
310
+ owner !== undefined &&
311
+ (probes.isProcessAlive ?? isProcessAlive)(owner.pid);
312
+ return { name, owner, pidAlive };
313
+ } catch (error) {
314
+ if (error.code === "ENOENT") return undefined;
315
+ throw error;
316
+ }
317
+ }
318
+
319
+ /** Capture one complete stable namespace view or refuse boundedly. */
320
+ function stableNamespaceInspection(namespace, namespaceStat, probes) {
321
+ for (
322
+ let attempt = 1;
323
+ attempt <= MAX_NAMESPACE_STABILITY_ATTEMPTS;
324
+ attempt += 1
325
+ ) {
326
+ assertNamespaceIdentity(namespace, namespaceStat);
327
+ const before = scanDirectNames(namespace, MAX_NAMESPACE_ENTRIES);
328
+ probes.afterNamespaceScan?.({
329
+ attempt,
330
+ phase: "before",
331
+ namespace,
332
+ names: before,
333
+ });
334
+ const candidates = before.map(name =>
335
+ inspectNamespaceCandidate(name, namespace, namespaceStat, probes)
336
+ );
337
+ probes.afterNamespaceScan?.({
338
+ attempt,
339
+ phase: "after",
340
+ namespace,
341
+ names: before,
342
+ });
343
+ assertNamespaceIdentity(namespace, namespaceStat);
344
+ const after = scanDirectNames(namespace, MAX_NAMESPACE_ENTRIES);
345
+ assertNamespaceIdentity(namespace, namespaceStat);
346
+ if (
347
+ candidates.every(candidate => candidate !== undefined) &&
348
+ sameNames(before, after)
349
+ ) {
350
+ return candidates;
351
+ }
352
+ }
353
+ throw new Error(
354
+ `lisa-scratch namespace snapshot did not stabilize after ${String(MAX_NAMESPACE_STABILITY_ATTEMPTS)} attempts`
355
+ );
356
+ }
357
+
358
+ /**
359
+ * Bounded read-only classification of `lisa-scratch` direct children.
360
+ * @param {string} root Canonical platform temp root
361
+ * @param {{isProcessAlive?: (pid: number) => boolean, processBirthFingerprintSnapshot?: (pids: readonly number[]) => ReadonlyMap<number, string | undefined>, afterNamespaceScan?: (event: {attempt: number, phase: "before"|"after", namespace: string, names: readonly string[]}) => void}} [probes] Bounded process-authority probes and deterministic churn seam
362
+ */
363
+ function inspectNamespace(root, probes = {}) {
364
+ const namespace = path.join(root, SCRATCH_NAMESPACE);
365
+ let namespaceStat;
366
+ try {
367
+ namespaceStat = fs.lstatSync(namespace);
368
+ if (namespaceStat.isSymbolicLink() || !namespaceStat.isDirectory()) {
369
+ throw new Error("lisa-scratch is not an authoritative directory");
370
+ }
371
+ const uid = process.getuid?.();
372
+ if (uid !== undefined && namespaceStat.uid !== uid) {
373
+ throw new Error("lisa-scratch uid does not match the current process");
374
+ }
375
+ if ((namespaceStat.mode & 0o777) !== 0o700) {
376
+ throw new Error("lisa-scratch mode must be 0700");
377
+ }
378
+ } catch (error) {
379
+ if (error.code === "ENOENT") {
380
+ return {
381
+ total: 0,
382
+ owned: 0,
383
+ live: 0,
384
+ unowned: 0,
385
+ entries: [],
386
+ suiteLabels: [],
387
+ validOwnerRecords: [],
388
+ };
389
+ }
390
+ throw error;
391
+ }
392
+ const inspected = stableNamespaceInspection(namespace, namespaceStat, probes);
393
+ const births = (
394
+ probes.processBirthFingerprintSnapshot ?? processBirthFingerprintSnapshot
395
+ )(
396
+ inspected.flatMap(entry =>
397
+ entry.owner !== undefined && entry.pidAlive ? [entry.owner.pid] : []
398
+ )
399
+ );
400
+ const entries = inspected.map(({ name, owner, pidAlive }) => {
401
+ const observed =
402
+ owner !== undefined && pidAlive ? births.get(owner.pid) : undefined;
403
+ if (owner !== undefined && pidAlive && observed === undefined) {
404
+ throw new Error(
405
+ `Scratch owner birth authority unavailable for live PID ${String(owner.pid)} (${name})`
406
+ );
407
+ }
408
+ if (
409
+ owner !== undefined &&
410
+ pidAlive &&
411
+ observed !== owner.processBirthFingerprint
412
+ ) {
413
+ throw new Error(
414
+ `Scratch owner birth authority mismatch for live PID ${String(owner.pid)} (${name})`
415
+ );
416
+ }
417
+ const live = owner !== undefined && pidAlive;
418
+ return {
419
+ name,
420
+ owned: owner !== undefined,
421
+ live,
422
+ ...(owner === undefined
423
+ ? {}
424
+ : {
425
+ suiteLabel: owner.suiteLabel,
426
+ pid: owner.pid,
427
+ token: owner.token,
428
+ }),
429
+ };
430
+ });
431
+ const validOwnerRecords = entries
432
+ .filter(entry => entry.owned)
433
+ .map(entry => ({
434
+ name: entry.name,
435
+ pid: entry.pid,
436
+ suiteLabel: entry.suiteLabel,
437
+ token: entry.token,
438
+ live: entry.live,
439
+ }))
440
+ .sort((left, right) => codePointCompare(left.name, right.name));
441
+ const suiteLabels = [
442
+ ...new Set(validOwnerRecords.map(record => record.suiteLabel)),
443
+ ].sort(codePointCompare);
444
+ const owned = entries.filter(entry => entry.owned).length;
445
+ const live = entries.filter(entry => entry.live).length;
446
+ return {
447
+ total: entries.length,
448
+ owned,
449
+ live,
450
+ unowned: entries.length - owned,
451
+ entries,
452
+ suiteLabels,
453
+ validOwnerRecords,
454
+ };
455
+ }
456
+
457
+ /**
458
+ * Build a complete snapshot of one authoritative temp root.
459
+ * @param {string} logicalRoot Root as supplied by the operator
460
+ * @param {number} nowMs Observation epoch milliseconds
461
+ * @param {{isProcessAlive?: (pid: number) => boolean, processBirthFingerprintSnapshot?: (pids: readonly number[]) => ReadonlyMap<number, string | undefined>}} [probes] Bounded process-authority probes
462
+ * @returns {object} Complete snapshot
463
+ */
464
+ export function buildTmpdirSnapshot(
465
+ logicalRoot,
466
+ nowMs = Date.now(),
467
+ probes = {}
468
+ ) {
469
+ const rootStat = fs.lstatSync(logicalRoot);
470
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
471
+ throw new Error("Temp root must be a real directory, not a symlink");
472
+ }
473
+ if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
474
+ throw new Error("Observation time must be a non-negative integer");
475
+ }
476
+ const canonicalRoot = fs.realpathSync(logicalRoot);
477
+ const names = scanDirectNames(canonicalRoot, MAX_TMPDIR_ENTRIES);
478
+ return {
479
+ schemaVersion: TMPDIR_GROWTH_SCHEMA_VERSION,
480
+ groupingVersion: TMPDIR_GROUPING_VERSION,
481
+ logicalRoot: path.resolve(logicalRoot),
482
+ canonicalRoot,
483
+ rootIdentity: { dev: rootStat.dev, ino: rootStat.ino },
484
+ observedAt: new Date(nowMs).toISOString(),
485
+ observedAtMs: nowMs,
486
+ complete: true,
487
+ entryNames: names,
488
+ prefixCounts: prefixCounts(names),
489
+ namespace: inspectNamespace(canonicalRoot, probes),
490
+ };
491
+ }
492
+
493
+ /** Demand two snapshots describe the same compatible root and monotonic time. */
494
+ function assertComparable(before, after) {
495
+ if (
496
+ before.schemaVersion !== TMPDIR_GROWTH_SCHEMA_VERSION ||
497
+ before.groupingVersion !== TMPDIR_GROUPING_VERSION ||
498
+ before.complete !== true
499
+ ) {
500
+ throw new Error("Previous temp-growth snapshot is malformed or partial");
501
+ }
502
+ if (
503
+ before.logicalRoot !== after.logicalRoot ||
504
+ before.canonicalRoot !== after.canonicalRoot ||
505
+ before.rootIdentity?.dev !== after.rootIdentity.dev ||
506
+ before.rootIdentity?.ino !== after.rootIdentity.ino
507
+ ) {
508
+ throw new Error("Previous temp-growth snapshot describes a different root");
509
+ }
510
+ if (after.observedAtMs <= before.observedAtMs) {
511
+ throw new Error("Temp-growth snapshots are non-monotonic");
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Compare two complete snapshots without hiding equal creation/removal.
517
+ * @param {object|undefined} before Previous snapshot
518
+ * @param {object} after Current snapshot
519
+ * @returns {object} Deterministic growth report
520
+ */
521
+ export function buildGrowthReport(before, after) {
522
+ if (before === undefined) {
523
+ return {
524
+ total: after.entryNames.length,
525
+ delta: null,
526
+ created: 0,
527
+ removed: 0,
528
+ unreclaimed: 0,
529
+ elapsedMs: null,
530
+ rateEntriesPerDay: null,
531
+ topPrefixes: prefixCounts(after.entryNames).slice(0, 10),
532
+ namespace: {
533
+ total: after.namespace.total,
534
+ owned: after.namespace.owned,
535
+ live: after.namespace.live,
536
+ unowned: after.namespace.unowned,
537
+ created: 0,
538
+ removed: 0,
539
+ unreclaimed: after.namespace.unowned,
540
+ newlyUnowned: 0,
541
+ },
542
+ violations: [],
543
+ };
544
+ }
545
+ assertComparable(before, after);
546
+ const beforeNames = new Set(before.entryNames);
547
+ const afterNames = new Set(after.entryNames);
548
+ const createdNames = after.entryNames.filter(name => !beforeNames.has(name));
549
+ const removedNames = before.entryNames.filter(name => !afterNames.has(name));
550
+ const elapsedMs = after.observedAtMs - before.observedAtMs;
551
+ const beforeNamespace = new Map(
552
+ before.namespace.entries.map(entry => [entry.name, entry])
553
+ );
554
+ const afterNamespace = new Map(
555
+ after.namespace.entries.map(entry => [entry.name, entry])
556
+ );
557
+ const namespaceCreated = [...afterNamespace.keys()].filter(
558
+ name => !beforeNamespace.has(name)
559
+ );
560
+ const namespaceRemoved = [...beforeNamespace.keys()].filter(
561
+ name => !afterNamespace.has(name)
562
+ );
563
+ const newlyUnowned = [...afterNamespace.keys()].filter(name => {
564
+ const beforeEntry = beforeNamespace.get(name);
565
+ const afterEntry = afterNamespace.get(name);
566
+ return (
567
+ afterEntry?.owned !== true &&
568
+ (beforeEntry === undefined || beforeEntry.owned === true)
569
+ );
570
+ });
571
+ const violations = [
572
+ ...createdNames
573
+ .filter(name => canonicalizeTmpPrefix(name) === "cdk.out*")
574
+ .map(name => `new direct cdk.out entry: ${name}`),
575
+ ...createdNames
576
+ .filter(name => name !== SCRATCH_NAMESPACE && name.startsWith("lisa-"))
577
+ .map(name => `new direct unowned Lisa entry: ${name}`),
578
+ ...newlyUnowned.map(name => `new unowned lisa-scratch child: ${name}`),
579
+ ].sort(codePointCompare);
580
+ return {
581
+ total: after.entryNames.length,
582
+ delta: after.entryNames.length - before.entryNames.length,
583
+ created: createdNames.length,
584
+ removed: removedNames.length,
585
+ unreclaimed: Math.max(0, createdNames.length - removedNames.length),
586
+ elapsedMs,
587
+ rateEntriesPerDay: Math.round(
588
+ ((createdNames.length - removedNames.length) * 86_400_000) / elapsedMs
589
+ ),
590
+ topPrefixes: prefixCounts(after.entryNames).slice(0, 10),
591
+ namespace: {
592
+ total: after.namespace.total,
593
+ owned: after.namespace.owned,
594
+ live: after.namespace.live,
595
+ unowned: after.namespace.unowned,
596
+ created: namespaceCreated.length,
597
+ removed: namespaceRemoved.length,
598
+ unreclaimed: Math.max(
599
+ 0,
600
+ namespaceCreated.length - namespaceRemoved.length
601
+ ),
602
+ newlyUnowned: newlyUnowned.length,
603
+ },
604
+ violations,
605
+ };
606
+ }
607
+
608
+ /** Parse the tiny CLI surface without accepting ambiguous positional input. */
609
+ function parseArgs(argv) {
610
+ const options = {
611
+ root: os.tmpdir(),
612
+ artifact: path.resolve(DEFAULT_TMPDIR_GROWTH_ARTIFACT),
613
+ nowMs: Date.now(),
614
+ };
615
+ for (let index = 0; index < argv.length; index += 1) {
616
+ const flag = argv[index];
617
+ const value = argv[index + 1];
618
+ if (flag === "--root" && value !== undefined) options.root = value;
619
+ else if (flag === "--artifact" && value !== undefined)
620
+ options.artifact = value;
621
+ else if (flag === "--now-ms" && value !== undefined)
622
+ options.nowMs = Number(value);
623
+ else throw new Error(`Unknown or incomplete argument: ${String(flag)}`);
624
+ index += 1;
625
+ }
626
+ return options;
627
+ }
628
+
629
+ /** Demand the CLI root names the process platform temp root exactly. */
630
+ function assertPlatformTempRoot(root) {
631
+ const platformRoot = os.tmpdir();
632
+ const suppliedStat = fs.lstatSync(root);
633
+ const platformStat = fs.lstatSync(platformRoot);
634
+ if (
635
+ suppliedStat.isSymbolicLink() ||
636
+ !suppliedStat.isDirectory() ||
637
+ platformStat.isSymbolicLink() ||
638
+ !platformStat.isDirectory() ||
639
+ path.resolve(root) !== path.resolve(platformRoot) ||
640
+ fs.realpathSync(root) !== fs.realpathSync(platformRoot) ||
641
+ suppliedStat.dev !== platformStat.dev ||
642
+ suppliedStat.ino !== platformStat.ino
643
+ ) {
644
+ throw new Error("--root must equal the current platform temp root");
645
+ }
646
+ }
647
+
648
+ /** Whether a value is a non-negative safe integer. */
649
+ function isCount(value) {
650
+ return Number.isSafeInteger(value) && value >= 0;
651
+ }
652
+
653
+ /** Whether an object exposes exactly the declared persisted keys. */
654
+ function hasExactKeys(value, keys) {
655
+ return (
656
+ value !== null &&
657
+ typeof value === "object" &&
658
+ isDeepStrictEqual(
659
+ Object.keys(value).sort(codePointCompare),
660
+ [...keys].sort(codePointCompare)
661
+ )
662
+ );
663
+ }
664
+
665
+ /** Whether a persisted namespace entry has one exact boolean-tagged shape. */
666
+ function isPersistedNamespaceEntry(entry) {
667
+ if (
668
+ entry === null ||
669
+ typeof entry !== "object" ||
670
+ typeof entry.name !== "string" ||
671
+ path.basename(entry.name) !== entry.name ||
672
+ entry.name === "." ||
673
+ entry.name === ".." ||
674
+ Buffer.byteLength(entry.name, "utf8") > MAX_TMPDIR_NAME_BYTES ||
675
+ typeof entry.owned !== "boolean" ||
676
+ typeof entry.live !== "boolean" ||
677
+ (entry.live && !entry.owned)
678
+ ) {
679
+ return false;
680
+ }
681
+ if (!entry.owned) {
682
+ return hasExactKeys(entry, ["name", "owned", "live"]);
683
+ }
684
+ return (
685
+ hasExactKeys(entry, [
686
+ "name",
687
+ "owned",
688
+ "live",
689
+ "suiteLabel",
690
+ "pid",
691
+ "token",
692
+ ]) &&
693
+ Number.isSafeInteger(entry.pid) &&
694
+ entry.pid > 0 &&
695
+ isBoundedOwnerText(entry.suiteLabel) &&
696
+ isBoundedOwnerText(entry.token)
697
+ );
698
+ }
699
+
700
+ /** Whether a persisted owner summary has the exact bounded shape. */
701
+ function isPersistedOwnerRecord(record) {
702
+ return (
703
+ hasExactKeys(record, ["name", "pid", "suiteLabel", "token", "live"]) &&
704
+ typeof record.name === "string" &&
705
+ path.basename(record.name) === record.name &&
706
+ record.name !== "." &&
707
+ record.name !== ".." &&
708
+ Buffer.byteLength(record.name, "utf8") <= MAX_TMPDIR_NAME_BYTES &&
709
+ Number.isSafeInteger(record.pid) &&
710
+ record.pid > 0 &&
711
+ isBoundedOwnerText(record.suiteLabel) &&
712
+ isBoundedOwnerText(record.token) &&
713
+ typeof record.live === "boolean"
714
+ );
715
+ }
716
+
717
+ /** Owner summaries are a canonical projection of owned namespace entries. */
718
+ function ownerRecordsFromEntries(entries) {
719
+ return entries
720
+ .filter(entry => entry.owned)
721
+ .map(entry => ({
722
+ name: entry.name,
723
+ pid: entry.pid,
724
+ suiteLabel: entry.suiteLabel,
725
+ token: entry.token,
726
+ live: entry.live,
727
+ }))
728
+ .sort((left, right) => codePointCompare(left.name, right.name));
729
+ }
730
+
731
+ /** Demand one persisted snapshot has the complete bounded current schema. */
732
+ function assertPersistedSnapshot(snapshot) {
733
+ if (
734
+ snapshot === null ||
735
+ typeof snapshot !== "object" ||
736
+ snapshot.schemaVersion !== TMPDIR_GROWTH_SCHEMA_VERSION ||
737
+ snapshot.groupingVersion !== TMPDIR_GROUPING_VERSION ||
738
+ snapshot.complete !== true ||
739
+ typeof snapshot.logicalRoot !== "string" ||
740
+ !path.isAbsolute(snapshot.logicalRoot) ||
741
+ typeof snapshot.canonicalRoot !== "string" ||
742
+ !path.isAbsolute(snapshot.canonicalRoot) ||
743
+ !isCount(snapshot.rootIdentity?.dev) ||
744
+ !isCount(snapshot.rootIdentity?.ino) ||
745
+ !isCount(snapshot.observedAtMs) ||
746
+ snapshot.observedAt !== new Date(snapshot.observedAtMs).toISOString() ||
747
+ !Array.isArray(snapshot.entryNames) ||
748
+ !Array.isArray(snapshot.prefixCounts) ||
749
+ snapshot.prefixCounts.length > MAX_UNIQUE_PREFIXES ||
750
+ !snapshot.prefixCounts.every(
751
+ entry =>
752
+ hasExactKeys(entry, ["prefix", "count"]) &&
753
+ isBoundedOwnerText(entry.prefix) &&
754
+ Number.isSafeInteger(entry.count) &&
755
+ entry.count > 0
756
+ ) ||
757
+ snapshot.namespace === null ||
758
+ typeof snapshot.namespace !== "object" ||
759
+ !Array.isArray(snapshot.namespace.entries) ||
760
+ snapshot.namespace.entries.length > MAX_NAMESPACE_ENTRIES ||
761
+ !Array.isArray(snapshot.namespace.suiteLabels) ||
762
+ !Array.isArray(snapshot.namespace.validOwnerRecords) ||
763
+ !isCount(snapshot.namespace.total) ||
764
+ !isCount(snapshot.namespace.owned) ||
765
+ !isCount(snapshot.namespace.live) ||
766
+ !isCount(snapshot.namespace.unowned)
767
+ ) {
768
+ throw new Error(
769
+ "Temp-growth artifact contains a malformed or partial snapshot"
770
+ );
771
+ }
772
+ if (
773
+ !snapshot.namespace.entries.every(isPersistedNamespaceEntry) ||
774
+ !snapshot.namespace.validOwnerRecords.every(isPersistedOwnerRecord) ||
775
+ !snapshot.namespace.suiteLabels.every(isBoundedOwnerText)
776
+ ) {
777
+ throw new Error(
778
+ "Temp-growth artifact snapshot has malformed ownership fields"
779
+ );
780
+ }
781
+ const normalizedNames = collectBoundedEntryNames(snapshot.entryNames);
782
+ const namespaceNames = collectBoundedEntryNames(
783
+ snapshot.namespace.entries.map(entry => entry.name),
784
+ MAX_NAMESPACE_ENTRIES
785
+ );
786
+ const suiteLabels = collectBoundedEntryNames(
787
+ snapshot.namespace.suiteLabels,
788
+ MAX_NAMESPACE_ENTRIES
789
+ );
790
+ const ownerNames = collectBoundedEntryNames(
791
+ snapshot.namespace.validOwnerRecords.map(record => record.name),
792
+ MAX_NAMESPACE_ENTRIES
793
+ );
794
+ const owned = snapshot.namespace.entries.filter(
795
+ entry => entry.owned === true
796
+ );
797
+ const live = snapshot.namespace.entries.filter(entry => entry.live === true);
798
+ const expectedOwnerRecords = ownerRecordsFromEntries(
799
+ snapshot.namespace.entries
800
+ );
801
+ const expectedSuiteLabels = [
802
+ ...new Set(expectedOwnerRecords.map(record => record.suiteLabel)),
803
+ ].sort(codePointCompare);
804
+ if (
805
+ normalizedNames.length !== new Set(normalizedNames).size ||
806
+ normalizedNames.some(
807
+ (name, index) => name !== snapshot.entryNames[index]
808
+ ) ||
809
+ JSON.stringify(prefixCounts(normalizedNames)) !==
810
+ JSON.stringify(snapshot.prefixCounts) ||
811
+ namespaceNames.length !== new Set(namespaceNames).size ||
812
+ namespaceNames.some(
813
+ (name, index) => name !== snapshot.namespace.entries[index].name
814
+ ) ||
815
+ suiteLabels.length !== new Set(suiteLabels).size ||
816
+ suiteLabels.some(
817
+ (label, index) => label !== snapshot.namespace.suiteLabels[index]
818
+ ) ||
819
+ ownerNames.length !== new Set(ownerNames).size ||
820
+ ownerNames.some(
821
+ (name, index) => name !== snapshot.namespace.validOwnerRecords[index].name
822
+ ) ||
823
+ !isDeepStrictEqual(
824
+ snapshot.namespace.validOwnerRecords,
825
+ expectedOwnerRecords
826
+ ) ||
827
+ !isDeepStrictEqual(snapshot.namespace.suiteLabels, expectedSuiteLabels) ||
828
+ snapshot.namespace.total !== snapshot.namespace.entries.length ||
829
+ snapshot.namespace.owned !== owned.length ||
830
+ snapshot.namespace.live !== live.length ||
831
+ snapshot.namespace.owned + snapshot.namespace.unowned !==
832
+ snapshot.namespace.total ||
833
+ snapshot.namespace.live > snapshot.namespace.owned
834
+ ) {
835
+ throw new Error("Temp-growth artifact snapshot is inconsistent");
836
+ }
837
+ }
838
+
839
+ /** Read and validate a bounded existing artifact. */
840
+ function readArtifact(artifactPath) {
841
+ try {
842
+ const stat = fs.lstatSync(artifactPath);
843
+ if (stat.isSymbolicLink() || !stat.isFile()) {
844
+ throw new Error("Temp-growth artifact must be a regular file");
845
+ }
846
+ if (stat.size > MAX_ARTIFACT_BYTES) {
847
+ throw new Error("Temp-growth artifact exceeds its byte bound");
848
+ }
849
+ const parsed = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
850
+ if (
851
+ parsed === null ||
852
+ typeof parsed !== "object" ||
853
+ parsed.schemaVersion !== TMPDIR_GROWTH_SCHEMA_VERSION ||
854
+ parsed.groupingVersion !== TMPDIR_GROUPING_VERSION ||
855
+ !Array.isArray(parsed.snapshots) ||
856
+ parsed.snapshots.length === 0 ||
857
+ parsed.snapshots.length > 2
858
+ ) {
859
+ throw new Error("Temp-growth artifact is malformed or incompatible");
860
+ }
861
+ parsed.snapshots.forEach(assertPersistedSnapshot);
862
+ const latest = parsed.snapshots.at(-1);
863
+ const previous = parsed.snapshots.at(-2);
864
+ if (
865
+ latest === undefined ||
866
+ !isDeepStrictEqual(parsed.report, buildGrowthReport(previous, latest))
867
+ ) {
868
+ throw new Error(
869
+ "Temp-growth artifact report is malformed or inconsistent"
870
+ );
871
+ }
872
+ return parsed;
873
+ } catch (error) {
874
+ if (error.code === "ENOENT") return undefined;
875
+ throw error;
876
+ }
877
+ }
878
+
879
+ /** Atomic artifact write performed only after a complete compatible scan. */
880
+ function writeArtifact(artifactPath, artifact) {
881
+ const content = `${JSON.stringify(artifact, null, 2)}\n`;
882
+ if (Buffer.byteLength(content, "utf8") > MAX_ARTIFACT_BYTES) {
883
+ throw new Error("Complete temp-growth artifact exceeds its byte bound");
884
+ }
885
+ fs.mkdirSync(path.dirname(artifactPath), { recursive: true });
886
+ const temporary = `${artifactPath}.tmp-${process.pid}-${Date.now()}`;
887
+ fs.writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
888
+ fs.renameSync(temporary, artifactPath);
889
+ }
890
+
891
+ /**
892
+ * Run one CLI measurement, returning its documented exit status.
893
+ * @param {readonly string[]} argv CLI arguments
894
+ * @param {{isProcessAlive?: (pid: number) => boolean, processBirthFingerprintSnapshot?: (pids: readonly number[]) => ReadonlyMap<number, string | undefined>}} [probes] Internal process-authority probes
895
+ * @returns {number} Documented process exit status
896
+ */
897
+ export function runTmpdirGrowth(argv = process.argv.slice(2), probes = {}) {
898
+ try {
899
+ const options = parseArgs(argv);
900
+ assertPlatformTempRoot(options.root);
901
+ const existing = readArtifact(options.artifact);
902
+ const current = buildTmpdirSnapshot(options.root, options.nowMs, probes);
903
+ const previous = existing?.snapshots.at(-1);
904
+ const report = buildGrowthReport(previous, current);
905
+ const snapshots = [...(existing?.snapshots ?? []), current].slice(-2);
906
+ const artifact = {
907
+ schemaVersion: TMPDIR_GROWTH_SCHEMA_VERSION,
908
+ groupingVersion: TMPDIR_GROUPING_VERSION,
909
+ snapshots,
910
+ report,
911
+ };
912
+ writeArtifact(options.artifact, artifact);
913
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
914
+ return report.violations.length > 0 ? 1 : 0;
915
+ } catch (error) {
916
+ process.stderr.write(
917
+ `Temp-growth measurement incomplete: ${String(error)}\n`
918
+ );
919
+ return 2;
920
+ }
921
+ }
922
+
923
+ if (invokedAsScript(import.meta.url)) {
924
+ process.exitCode = runTmpdirGrowth();
925
+ }