@crewhaus/tool-buildperf 0.7.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.
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Artifact size, and the single condition that makes two sizes comparable.
3
+ *
4
+ * Compressing bytes is the easy half. The half that costs a team a morning is
5
+ * comparability: a gzip size is not a property of a file, it is a property of
6
+ * a file AND the compressor settings. Level 6 against level 9 on the same
7
+ * bundle differs by several percent — the same order as the regression anyone
8
+ * is looking for — so a baseline produced at one level and a head measured at
9
+ * another produce a confident, entirely fabricated "+4.1%". Every number this
10
+ * module produces therefore travels with the parameters that produced it, and
11
+ * `parametersMismatch` exists so the caller can refuse rather than subtract.
12
+ *
13
+ * Nothing here is statistical. Compression at fixed parameters is
14
+ * deterministic, so a one-byte difference is a real one-byte difference and
15
+ * there is no noise floor to declare. That is the whole reason size gating is
16
+ * a different shape of problem from benchmark gating, which is next door in
17
+ * `./bench`.
18
+ */
19
+ import { constants as zlib, brotliCompressSync, gzipSync } from "node:zlib";
20
+ export const COMPRESSION_ALGORITHMS = ["none", "gzip", "brotli"];
21
+ export class SizeError extends Error {
22
+ name = "SizeError";
23
+ }
24
+ /** zlib's own default is 6; bundler size reporters quote level 9, so that is the default here. */
25
+ const GZIP_DEFAULT_LEVEL = 9;
26
+ /** Brotli's maximum quality, which is what every published "brotli size" means. */
27
+ const BROTLI_DEFAULT_LEVEL = 11;
28
+ /**
29
+ * Fill in and validate the parameters for a run.
30
+ *
31
+ * A level outside the algorithm's range is a refusal, not a clamp: gzip
32
+ * silently accepting 11 (a brotli quality) and compressing at 9 would produce
33
+ * a report claiming a level it did not use, which is the exact failure this
34
+ * module exists to prevent.
35
+ */
36
+ export function compressionParameters(algorithm, level) {
37
+ if (algorithm === "none") {
38
+ if (level !== undefined) {
39
+ throw new SizeError(`algorithm "none" does not compress, so it has no level (got ${level})`);
40
+ }
41
+ return { algorithm, level: null, tuning: {} };
42
+ }
43
+ const max = algorithm === "gzip" ? 9 : 11;
44
+ const chosen = level ?? (algorithm === "gzip" ? GZIP_DEFAULT_LEVEL : BROTLI_DEFAULT_LEVEL);
45
+ if (!Number.isInteger(chosen) || chosen < 0 || chosen > max) {
46
+ throw new SizeError(`${algorithm} levels run 0-${max}, got ${chosen}${algorithm === "gzip" && chosen > 9
47
+ ? " — 10 and 11 are brotli qualities, not gzip levels"
48
+ : ""}`);
49
+ }
50
+ return algorithm === "gzip"
51
+ ? {
52
+ algorithm,
53
+ level: chosen,
54
+ // memLevel and strategy move the output too; pinned so the recorded
55
+ // pair (algorithm, level) really does determine the byte count.
56
+ tuning: { memLevel: 8, strategy: zlib.Z_DEFAULT_STRATEGY, windowBits: 15 },
57
+ }
58
+ : {
59
+ algorithm,
60
+ level: chosen,
61
+ tuning: { lgwin: 22, mode: zlib.BROTLI_MODE_GENERIC },
62
+ };
63
+ }
64
+ /**
65
+ * The string two parameter sets must agree on for a comparison to mean
66
+ * anything. Sorted, so key order in a stored baseline cannot change it.
67
+ */
68
+ export function comparabilityKey(p) {
69
+ const tuning = Object.keys(p.tuning)
70
+ .sort()
71
+ .map((k) => `${k}=${p.tuning[k]}`)
72
+ .join(",");
73
+ return `${p.algorithm}/level=${p.level ?? "n/a"}${tuning === "" ? "" : `/${tuning}`}`;
74
+ }
75
+ /**
76
+ * Why these two sets of parameters cannot be compared, or `null` when they
77
+ * can. The message names the fields that differ, because "incomparable" on
78
+ * its own sends the reader to the source.
79
+ */
80
+ export function parametersMismatch(baseline, head) {
81
+ if (baseline.algorithm !== head.algorithm) {
82
+ return `the baseline was measured with ${baseline.algorithm} and this run used ${head.algorithm}; compressed sizes from two algorithms are different measurements, not a change`;
83
+ }
84
+ if (baseline.level !== head.level) {
85
+ return `the baseline was measured at ${baseline.algorithm} level ${baseline.level} and this run at level ${head.level}; the difference between two levels is several percent on typical bundles, which would read as a regression that did not happen`;
86
+ }
87
+ const differing = [...new Set([...Object.keys(baseline.tuning), ...Object.keys(head.tuning)])]
88
+ .filter((k) => baseline.tuning[k] !== head.tuning[k])
89
+ .sort();
90
+ if (differing.length > 0) {
91
+ return `the baseline and this run disagree on ${differing
92
+ .map((k) => `${k} (${baseline.tuning[k] ?? "unset"} vs ${head.tuning[k] ?? "unset"})`)
93
+ .join(", ")}; those settings change the compressed size, so the two are not comparable`;
94
+ }
95
+ return null;
96
+ }
97
+ /**
98
+ * Why this baseline's ENTRIES cannot be differenced against a run measured
99
+ * under `algorithm`, or `null` when they can.
100
+ *
101
+ * `parametersMismatch` guards the compressor settings. This guards the
102
+ * column. Every entry carries two numbers — a raw size and a compressed one —
103
+ * and which of them a delta is taken on is decided by the algorithm, so a
104
+ * baseline that declares gzip but stores no `compressedBytes` makes the
105
+ * obvious `compressed ?? raw` fallback subtract a gzip size from a raw one.
106
+ * That lands near -70% on real bundles: not a number a reader distrusts, a
107
+ * number a reader celebrates, and it hides whatever the change actually did.
108
+ * The reverse — a compressed column under algorithm "none" — fabricates a
109
+ * regression the same way, so both directions are refused rather than
110
+ * silently filled in.
111
+ */
112
+ export function baselineBasisMismatch(algorithm, entries) {
113
+ if (entries.length === 0)
114
+ return null;
115
+ const compresses = algorithm !== "none";
116
+ const offenders = entries
117
+ .filter((e) => ((e.compressedBytes ?? null) === null) === compresses)
118
+ .map((e) => e.path);
119
+ if (offenders.length === 0)
120
+ return null;
121
+ const named = offenders.slice(0, 5).join(", ");
122
+ const more = offenders.length > 5 ? `, and ${offenders.length - 5} more` : "";
123
+ return compresses
124
+ ? `the baseline declares ${algorithm} but has no compressedBytes on ${offenders.length} of its ${entries.length} entries (${named}${more}); this run's sizes are compressed, so pairing them would subtract a compressed size from a raw one and report a saving of roughly the compression ratio. Re-record it from a report of this tool`
125
+ : `the baseline declares algorithm "none" but carries a compressedBytes on ${offenders.length} of its ${entries.length} entries (${named}${more}), and this run measured raw bytes; the two columns are different measurements, not a change`;
126
+ }
127
+ /** Compressed byte count under `parameters`; the raw length for `none`. */
128
+ export function compressedSize(bytes, parameters) {
129
+ if (parameters.algorithm === "none")
130
+ return bytes.byteLength;
131
+ if (parameters.algorithm === "gzip") {
132
+ return gzipSync(bytes, {
133
+ level: parameters.level ?? GZIP_DEFAULT_LEVEL,
134
+ memLevel: parameters.tuning["memLevel"] ?? 8,
135
+ strategy: parameters.tuning["strategy"] ?? zlib.Z_DEFAULT_STRATEGY,
136
+ windowBits: parameters.tuning["windowBits"] ?? 15,
137
+ }).byteLength;
138
+ }
139
+ return brotliCompressSync(bytes, {
140
+ params: {
141
+ [zlib.BROTLI_PARAM_QUALITY]: parameters.level ?? BROTLI_DEFAULT_LEVEL,
142
+ [zlib.BROTLI_PARAM_LGWIN]: parameters.tuning["lgwin"] ?? 22,
143
+ [zlib.BROTLI_PARAM_MODE]: parameters.tuning["mode"] ?? zlib.BROTLI_MODE_GENERIC,
144
+ // SIZE_HINT is deliberately not set. It improves the ratio and is what a
145
+ // bundler passes, but it makes the output depend on a value the caller
146
+ // never sees, so two tools measuring "the brotli size" would disagree.
147
+ },
148
+ }).byteLength;
149
+ }
150
+ // --- joining a hashed build against a baseline ------------------------------
151
+ export const JOIN_MODES = ["exact", "auto", "custom"];
152
+ /**
153
+ * Whether a filename segment looks like a content hash rather than a name.
154
+ *
155
+ * Deliberately conservative in one direction only: `chunk`, `min`, `esm` and
156
+ * `vendor` must never be eaten, because normalizing a real name merges two
157
+ * different artifacts into one row. Pure hex needs 6 characters (webpack's
158
+ * shortest common `[contenthash:6]`); a mixed alphabet needs 8 AND both a
159
+ * digit and a letter (Vite's base64url ids, e.g. `BXaGz2Qm`). A lowercase
160
+ * word of 6+ letters that happens to be all hex — `decade`, `defaced` — is
161
+ * the known false positive, which is why every entry reports the key it was
162
+ * joined on rather than just the delta.
163
+ */
164
+ export function looksLikeHash(segment) {
165
+ if (!/^[0-9a-zA-Z_]+$/.test(segment))
166
+ return false;
167
+ if (/^[0-9a-f]{6,}$/i.test(segment))
168
+ return true;
169
+ return segment.length >= 8 && /[0-9]/.test(segment) && /[a-zA-Z]/.test(segment);
170
+ }
171
+ /**
172
+ * The key an entry joins on.
173
+ *
174
+ * `exact` is the default and joins on the path as written. It is the right
175
+ * choice for a build with stable filenames and the wrong one for a hashed
176
+ * build, where it matches NOTHING on every run — which reads as "every file
177
+ * is new", not as "the join is broken". `auto` strips hash-shaped segments
178
+ * from the basename; `custom` applies the caller's own pattern, which is the
179
+ * escape hatch for a bundler whose scheme the heuristic does not recognise.
180
+ */
181
+ export function joinKey(path, mode, custom) {
182
+ if (mode === "exact")
183
+ return { key: path, normalized: false };
184
+ if (mode === "custom") {
185
+ if (custom === undefined)
186
+ throw new SizeError('join mode "custom" needs a pattern');
187
+ const key = path.replace(custom, "[hash]");
188
+ return { key, normalized: key !== path };
189
+ }
190
+ const cut = path.lastIndexOf("/");
191
+ const dir = cut === -1 ? "" : path.slice(0, cut + 1);
192
+ const base = path.slice(cut + 1);
193
+ // Split on both separators at once: `app-4f2a1c.js` (Vite, Rollup) and
194
+ // `app.4f2a1c.js` (webpack) are the same idea with a different delimiter,
195
+ // and a build can contain both.
196
+ const parts = base.split(/([.-])/);
197
+ let normalized = false;
198
+ const rebuilt = parts.map((part, i) => {
199
+ // Odd indices are the captured separators; index 0 is the stem, which is
200
+ // never a hash on its own — `4f2a1c.js` as a whole filename carries no
201
+ // other identity, and blanking it would merge every such file.
202
+ if (i === 0 || i % 2 === 1)
203
+ return part;
204
+ if (!looksLikeHash(part))
205
+ return part;
206
+ normalized = true;
207
+ return "[hash]";
208
+ });
209
+ return { key: `${dir}${rebuilt.join("")}`, normalized };
210
+ }
211
+ /**
212
+ * Long enough for any real budget, short enough that the matcher's recursion
213
+ * depth (one frame per token) cannot reach the stack limit.
214
+ */
215
+ export const MAX_GLOB_LENGTH = 512;
216
+ function tokenizeGlob(pattern) {
217
+ if (pattern.length > MAX_GLOB_LENGTH) {
218
+ throw new SizeError(`glob pattern is ${pattern.length} characters, over the ${MAX_GLOB_LENGTH}-character limit; a budget pattern that long is not a path shape`);
219
+ }
220
+ const tokens = [];
221
+ let literal = "";
222
+ const flush = () => {
223
+ if (literal !== "") {
224
+ tokens.push({ kind: "literal", text: literal });
225
+ literal = "";
226
+ }
227
+ };
228
+ for (let i = 0; i < pattern.length; i++) {
229
+ const ch = pattern[i];
230
+ if (ch === "*") {
231
+ flush();
232
+ if (pattern[i + 1] === "*") {
233
+ if (pattern[i + 2] === "/") {
234
+ tokens.push({ kind: "anyDir" });
235
+ i += 2;
236
+ continue;
237
+ }
238
+ tokens.push({ kind: "any" });
239
+ i += 1;
240
+ continue;
241
+ }
242
+ tokens.push({ kind: "segment" });
243
+ continue;
244
+ }
245
+ if (ch === "?") {
246
+ flush();
247
+ tokens.push({ kind: "one" });
248
+ continue;
249
+ }
250
+ literal += ch;
251
+ }
252
+ flush();
253
+ return tokens;
254
+ }
255
+ /**
256
+ * Compile a pattern once, match many paths with it.
257
+ *
258
+ * This deliberately does NOT compile to a regular expression. `*` and `**`
259
+ * become `[^/]*` and `.*`, and a backtracking engine handed several of those
260
+ * separated by literals explores every way of splitting the string between
261
+ * them: `*a*a*a…*b` — a 49-character pattern — against sixty `a`s does not
262
+ * finish, and a budget pattern is caller-supplied. The state here is only
263
+ * (token index, offset in the path), so memoising it bounds the search at
264
+ * tokens x length cells no matter how the wildcards are arranged.
265
+ *
266
+ * Callers with many paths should hold the matcher: recompiling per path
267
+ * multiplies the tokenizer by the size of the build.
268
+ */
269
+ export function compileGlob(pattern) {
270
+ const tokens = tokenizeGlob(pattern);
271
+ const width = tokens.length;
272
+ return (path) => {
273
+ const n = path.length;
274
+ const stride = n + 1;
275
+ // 0 = not yet answered, 1 = no, 2 = yes. `match` only ever descends to a
276
+ // higher token index, so a cell can never be re-entered while it is being
277
+ // computed and there is no need for an "in progress" state.
278
+ const memo = new Uint8Array((width + 1) * stride);
279
+ const match = (j, i) => {
280
+ const cell = j * stride + i;
281
+ const cached = memo[cell];
282
+ if (cached !== 0)
283
+ return cached === 2;
284
+ let answer;
285
+ if (j === width) {
286
+ answer = i === n;
287
+ }
288
+ else {
289
+ const token = tokens[j];
290
+ switch (token.kind) {
291
+ case "literal":
292
+ answer = path.startsWith(token.text, i) && match(j + 1, i + token.text.length);
293
+ break;
294
+ case "one":
295
+ answer = i < n && path[i] !== "/" && match(j + 1, i + 1);
296
+ break;
297
+ case "segment": {
298
+ answer = false;
299
+ for (let k = i;; k++) {
300
+ if (match(j + 1, k)) {
301
+ answer = true;
302
+ break;
303
+ }
304
+ // A segment wildcard stops at the separator it is not allowed to
305
+ // cross, which is what makes `*.js` miss `dist/app.js`.
306
+ if (k >= n || path[k] === "/")
307
+ break;
308
+ }
309
+ break;
310
+ }
311
+ case "any": {
312
+ answer = false;
313
+ for (let k = i; k <= n; k++) {
314
+ if (match(j + 1, k)) {
315
+ answer = true;
316
+ break;
317
+ }
318
+ }
319
+ break;
320
+ }
321
+ case "anyDir": {
322
+ answer = match(j + 1, i);
323
+ for (let k = i; !answer && k < n; k++) {
324
+ if (path[k] === "/")
325
+ answer = match(j + 1, k + 1);
326
+ }
327
+ break;
328
+ }
329
+ }
330
+ }
331
+ memo[cell] = answer ? 2 : 1;
332
+ return answer;
333
+ };
334
+ return match(0, 0);
335
+ };
336
+ }
337
+ export function globMatch(pattern, path) {
338
+ return compileGlob(pattern)(path);
339
+ }
@@ -0,0 +1,42 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ /** Raised when a caller-supplied path resolves outside the workspace root. */
3
+ export declare class ToolPermissionError extends CrewhausError {
4
+ readonly name = "ToolPermissionError";
5
+ readonly toolName: string;
6
+ readonly path: string;
7
+ constructor(toolName: string, attemptedPath: string);
8
+ }
9
+ /**
10
+ * A validated path, in both forms the tools need.
11
+ *
12
+ * `real` has every in-workspace symlink already resolved and is what I/O
13
+ * should use — operating on it closes most of the check-to-use window.
14
+ * `abs` is the lexical resolution, which is what a tool must use when the
15
+ * question is about the link ITSELF (`Stat` reporting a symlink target,
16
+ * `RemovePath` deleting a link rather than its destination).
17
+ */
18
+ export type SafePath = {
19
+ readonly abs: string;
20
+ readonly real: string;
21
+ /** The path as the caller wrote it, for echoing back in results. */
22
+ readonly given: string;
23
+ /** Slash-separated path relative to the workspace root. */
24
+ readonly rel: string;
25
+ };
26
+ /** The workspace root, resolved. Every path is contained within it. */
27
+ export declare function workspaceRoot(): string;
28
+ export declare function resolveSafe(toolName: string, rel: string, root?: string): SafePath;
29
+ /** A path relative to the workspace root, with `/` separators on every OS. */
30
+ export declare function toPosix(p: string): string;
31
+ /**
32
+ * True when `candidate` is `parent` or lives underneath it. Both must already
33
+ * be absolute. Used to keep an archive entry, or a symlink inside an
34
+ * extracted tree, from reaching outside its destination.
35
+ */
36
+ export declare function isInside(parent: string, candidate: string): boolean;
37
+ /**
38
+ * Reject an archive member name that would escape its destination: an
39
+ * absolute path, a Windows drive prefix, or any `..` segment. Checked on the
40
+ * NAME, before extraction, because after extraction the damage is done.
41
+ */
42
+ export declare function archiveEntryEscapes(name: string): boolean;
package/dist/paths.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Path containment — the gate every caller-supplied path in this package
3
+ * passes through before any syscall touches it.
4
+ *
5
+ * The approach is the one `@crewhaus/tool-fs` uses, and for the same reasons:
6
+ * a lexical check alone is fooled by a symlink that lives inside the
7
+ * workspace and points outside it (CWE-59), so the real path is checked too.
8
+ * The workspace root is `process.cwd()`, matching tool-fs, so a harness that
9
+ * trusts one tool's boundary gets the same boundary here.
10
+ */
11
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
12
+ import * as path from "node:path";
13
+ import { CrewhausError } from "@crewhaus/errors";
14
+ /** Raised when a caller-supplied path resolves outside the workspace root. */
15
+ export class ToolPermissionError extends CrewhausError {
16
+ name = "ToolPermissionError";
17
+ toolName;
18
+ path;
19
+ constructor(toolName, attemptedPath) {
20
+ super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
21
+ this.toolName = toolName;
22
+ this.path = attemptedPath;
23
+ }
24
+ }
25
+ /** The workspace root, resolved. Every path is contained within it. */
26
+ export function workspaceRoot() {
27
+ return path.resolve(process.cwd());
28
+ }
29
+ /**
30
+ * Resolve `rel` inside the workspace, or throw `ToolPermissionError`.
31
+ *
32
+ * The leaf is allowed not to exist — every tool here that creates something
33
+ * needs to validate a destination before it is made — so the deepest
34
+ * EXISTING ancestor is realpath'd and the missing tail re-appended. Any
35
+ * failure other than that walk fails closed.
36
+ */
37
+ /**
38
+ * True when the NAME exists, whether or not it leads anywhere.
39
+ *
40
+ * `existsSync` FOLLOWS symlinks, so it answers false for a link whose target
41
+ * is missing — and that is exactly the case that matters: a dangling link is
42
+ * still a door, because `open(…, "w")` through `evil -> /outside/x` CREATES
43
+ * `/outside/x`. Probing with `lstat` keeps the link's name in the part of the
44
+ * path that gets resolved, rather than in the "does not exist yet" tail that
45
+ * is re-appended to the root verbatim and so passes the containment check.
46
+ */
47
+ function nameExists(p) {
48
+ try {
49
+ lstatSync(p);
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * Where `target` would actually land, with every symlink on the way already
58
+ * followed — including one whose own target does not exist yet.
59
+ *
60
+ * `realpathSync` gives up with ENOENT on a dangling link, which would leave
61
+ * that link unresolved and let it stand in for a plain missing file. So the
62
+ * deepest ancestor that exists as a NAME is resolved, a dangling one is
63
+ * followed a hop by hand, and the components that do not exist are appended.
64
+ * The result is the path an `open` would create, which is the only path worth
65
+ * checking containment against.
66
+ */
67
+ function resolveLocation(target, depth = 0) {
68
+ if (depth > 40)
69
+ throw new Error(`symlink chain at "${target}" is too long to resolve`);
70
+ let probe = target;
71
+ const tail = [];
72
+ while (!nameExists(probe)) {
73
+ tail.unshift(path.basename(probe));
74
+ const parent = path.dirname(probe);
75
+ if (parent === probe)
76
+ break; // reached the filesystem root
77
+ probe = parent;
78
+ }
79
+ let probeReal;
80
+ try {
81
+ probeReal = realpathSync(probe);
82
+ }
83
+ catch (err) {
84
+ if (err.code !== "ENOENT")
85
+ throw err;
86
+ // The name is there but `realpath` cannot finish it: a symlink with a
87
+ // missing target. `readlinkSync` throws EINVAL on anything else, which
88
+ // fails closed.
89
+ const link = readlinkSync(probe);
90
+ // A RELATIVE target resolves against the directory that actually CONTAINS
91
+ // the link, which is not the link's lexical parent when that parent is
92
+ // itself reached through a symlink. `<root>/dirlink/x -> ../y` with
93
+ // `dirlink` pointing out of the root really lands at `<elsewhere>/y`, but
94
+ // measured from the lexical parent it reads as `<root>/y` — an in-root
95
+ // path the caller's path does not lead to. So the parent is made real
96
+ // first. An absolute target ignores the base.
97
+ const base = realpathSync(path.dirname(probe));
98
+ probeReal = resolveLocation(path.resolve(base, link), depth + 1);
99
+ }
100
+ return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
101
+ }
102
+ export function resolveSafe(toolName, rel, root = workspaceRoot()) {
103
+ const rootResolved = path.resolve(root);
104
+ const abs = path.resolve(rootResolved, rel);
105
+ // 1) Lexical containment — rejects `..` and absolute escapes. The trailing
106
+ // separator avoids the `/root` vs `/root-sibling` prefix pitfall.
107
+ if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
108
+ throw new ToolPermissionError(toolName, rel);
109
+ }
110
+ let real;
111
+ try {
112
+ const rootReal = realpathSync(rootResolved);
113
+ real = resolveLocation(abs);
114
+ // 2) Symlink-aware containment. The real path must land inside the real
115
+ // root, so an in-workspace symlink pointing at /etc is refused here.
116
+ if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) {
117
+ throw new ToolPermissionError(toolName, rel);
118
+ }
119
+ }
120
+ catch (err) {
121
+ if (err instanceof ToolPermissionError)
122
+ throw err;
123
+ throw new ToolPermissionError(toolName, rel);
124
+ }
125
+ return {
126
+ abs,
127
+ real,
128
+ given: rel,
129
+ rel: toPosix(path.relative(rootResolved, abs)),
130
+ };
131
+ }
132
+ /** A path relative to the workspace root, with `/` separators on every OS. */
133
+ export function toPosix(p) {
134
+ return path.sep === "/" ? p : p.split(path.sep).join("/");
135
+ }
136
+ /**
137
+ * True when `candidate` is `parent` or lives underneath it. Both must already
138
+ * be absolute. Used to keep an archive entry, or a symlink inside an
139
+ * extracted tree, from reaching outside its destination.
140
+ */
141
+ export function isInside(parent, candidate) {
142
+ const p = path.resolve(parent);
143
+ const c = path.resolve(candidate);
144
+ return c === p || c.startsWith(`${p}${path.sep}`);
145
+ }
146
+ /**
147
+ * Reject an archive member name that would escape its destination: an
148
+ * absolute path, a Windows drive prefix, or any `..` segment. Checked on the
149
+ * NAME, before extraction, because after extraction the damage is done.
150
+ */
151
+ export function archiveEntryEscapes(name) {
152
+ const normalized = name.replace(/\\/g, "/");
153
+ if (normalized.startsWith("/"))
154
+ return true;
155
+ if (/^[A-Za-z]:/.test(normalized))
156
+ return true;
157
+ return normalized.split("/").some((segment) => segment === "..");
158
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@crewhaus/tool-buildperf",
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "description": "Deterministic build-performance gates: bundle size, benchmark comparison and flaky-test detection against a baseline",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "bun test src"
16
+ },
17
+ "dependencies": {
18
+ "@crewhaus/errors": "0.7.0",
19
+ "@crewhaus/tool-builder": "0.7.0",
20
+ "@crewhaus/tool-catalog": "0.7.0",
21
+ "@crewhaus/tool-math": "0.7.0",
22
+ "zod": "^3.23.8"
23
+ },
24
+ "devDependencies": {
25
+ "@crewhaus/tool-executor": "workspace:*"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "author": {
29
+ "name": "Max Meier",
30
+ "email": "max@crewhaus.ai",
31
+ "url": "https://crewhaus.ai"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/crewhaus/factory.git",
36
+ "directory": "packages/tool-buildperf"
37
+ },
38
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-buildperf#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/crewhaus/factory/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
46
+ }