@codyswann/lisa 4.37.1 → 4.37.3
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/all/copy-contents/gitignore +6 -0
- package/all/copy-overwrite/scripts/check-third-party-action-pins.mjs +6 -2
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +14 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +39 -12
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +5 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +11 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-write-ticket/SKILL.md +11 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +11 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +30 -2
- package/plugins/lisa/hooks/inject-resolved-config.mjs +26 -7
- package/plugins/lisa/rules/eager/config-resolution.md +3 -1
- package/plugins/lisa/rules/reference/config-resolution.md +1 -1
- package/plugins/lisa/scripts/intake-blocker-reprobe.mjs +120 -3
- package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +11 -0
- package/plugins/lisa/skills/lisa-jira-write-ticket/SKILL.md +11 -0
- package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +11 -0
- package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +30 -2
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/intake-blocker-reprobe.mjs +120 -3
- package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +11 -0
- package/plugins/lisa-agy/skills/lisa-jira-write-ticket/SKILL.md +11 -0
- package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +11 -0
- package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +30 -2
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/inject-resolved-config.mjs +26 -7
- package/plugins/lisa-copilot/rules/eager/config-resolution.md +3 -1
- package/plugins/lisa-copilot/rules/reference/config-resolution.md +1 -1
- package/plugins/lisa-copilot/scripts/intake-blocker-reprobe.mjs +120 -3
- package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +11 -0
- package/plugins/lisa-copilot/skills/lisa-jira-write-ticket/SKILL.md +11 -0
- package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +11 -0
- package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +30 -2
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/inject-resolved-config.mjs +26 -7
- package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +1 -1
- package/plugins/lisa-cursor/rules/config-resolution.mdc +3 -1
- package/plugins/lisa-cursor/scripts/intake-blocker-reprobe.mjs +120 -3
- package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +11 -0
- package/plugins/lisa-cursor/skills/lisa-jira-write-ticket/SKILL.md +11 -0
- package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +11 -0
- package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +30 -2
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/inject-resolved-config.mjs +26 -7
- package/plugins/src/base/rules/eager/config-resolution.md +3 -1
- package/plugins/src/base/rules/reference/config-resolution.md +1 -1
- package/plugins/src/base/scripts/intake-blocker-reprobe.mjs +120 -3
- package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +11 -0
- package/plugins/src/base/skills/lisa-jira-write-ticket/SKILL.md +11 -0
- package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +11 -0
- package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +30 -2
- package/scripts/check-third-party-action-pins.mjs +6 -2
- package/scripts/measure-tmpdir-growth.mjs +925 -0
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +5 -3
|
@@ -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
|
+
}
|