spectrum-cli 5.0.2 → 5.1.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,467 @@
1
+ // Parse a deliberately bounded subset of Next's build recipes. Never eval code.
2
+ const { ensure, safePath, sha, SEMVER, json, equal } = require("../automation");
3
+ const { purl } = require("./schema");
4
+ class Unresolved extends Error {}
5
+ const reject = (message) => {
6
+ throw new Unresolved(message);
7
+ };
8
+ const check = (condition, message) => {
9
+ if (!condition) reject(message);
10
+ };
11
+ const packageRoot = (request) =>
12
+ request.startsWith("@")
13
+ ? request.split("/").slice(0, 2).join("/")
14
+ : request.split("/")[0];
15
+ function nccOptions(text) {
16
+ check(text.length <= 8192, "ncc options limit exceeded");
17
+ const token =
18
+ /\s+|\/\/[^\n]*|\/\*.*?\*\/|'[^'\\\r\n]*'|"[^"\\\r\n]*"|\.\.\.|[A-Za-z_$][\w$]*|[{},:]/gsy;
19
+ const tokens = [];
20
+ let position = 0;
21
+ while (position < text.length) {
22
+ token.lastIndex = position;
23
+ const match = token.exec(text);
24
+ check(match, "unsupported ncc expression");
25
+ position = token.lastIndex;
26
+ if (
27
+ !/^\s+(?![\s\S])/.test(match[0]) &&
28
+ !match[0].startsWith("//") &&
29
+ !match[0].startsWith("/*")
30
+ )
31
+ tokens.push(match[0]);
32
+ }
33
+ check(tokens.length <= 512, "ncc options limit exceeded");
34
+ position = 0;
35
+ const take = () => {
36
+ check(position < tokens.length, "incomplete ncc options");
37
+ return tokens[position++];
38
+ };
39
+ const expect = (expected) =>
40
+ check(take() === expected, "unsupported ncc options");
41
+ const key = (value) => {
42
+ if (/^["']/.test(value)) return value.slice(1, -1);
43
+ check(
44
+ /^[A-Za-z_$][\w$]*(?![\s\S])/.test(value),
45
+ "unsupported ncc property",
46
+ );
47
+ return value;
48
+ };
49
+ const literal = (value) => {
50
+ check(/^["']/.test(value), "ncc string literal required");
51
+ return value.slice(1, -1);
52
+ };
53
+ function externalMap() {
54
+ expect("...");
55
+ expect("externals");
56
+ const mappings = {};
57
+ while (true) {
58
+ const separator = take();
59
+ if (separator === "}") return mappings;
60
+ check(separator === ",", "unsupported externals object");
61
+ const item = take();
62
+ if (item === "}") return mappings;
63
+ const name = key(item);
64
+ check(
65
+ !Object.hasOwn(mappings, name) &&
66
+ !["__proto__", "constructor", "prototype"].includes(name),
67
+ "duplicate or unsafe external key",
68
+ );
69
+ expect(":");
70
+ const destination = literal(take());
71
+ check(
72
+ /^(?:@[A-Za-z0-9_.-]+\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*(?![\s\S])/.test(
73
+ name,
74
+ ),
75
+ "unsupported external request",
76
+ );
77
+ check(
78
+ /^next\/dist\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*(?![\s\S])/.test(
79
+ destination,
80
+ ),
81
+ "unsupported external destination",
82
+ );
83
+ check(safePath(name) && safePath(destination), "unsafe external path");
84
+ mappings[name] = destination;
85
+ }
86
+ }
87
+ const options = {};
88
+ while (position < tokens.length) {
89
+ const nameToken = take(),
90
+ name = key(nameToken);
91
+ check(
92
+ !Object.hasOwn(options, name) &&
93
+ ["packageName", "bundleName", "externals", "target"].includes(name),
94
+ "duplicate or unsupported ncc option",
95
+ );
96
+ let value;
97
+ if (
98
+ name === "externals" &&
99
+ (position === tokens.length || tokens[position] === ",")
100
+ ) {
101
+ check(nameToken === "externals", "unsupported quoted shorthand");
102
+ value = {};
103
+ } else {
104
+ expect(":");
105
+ value = take();
106
+ if (name === "externals") {
107
+ if (value === "{") value = externalMap();
108
+ else {
109
+ check(value === "externals", "unsupported externals value");
110
+ value = {};
111
+ }
112
+ } else value = literal(value);
113
+ }
114
+ options[name] = value;
115
+ if (position < tokens.length) expect(",");
116
+ }
117
+ return options;
118
+ }
119
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
+ function recipeDetails(taskfile, target, manifestName) {
121
+ const functions = taskfile.split(/^(?=export (?:async )?function )/m);
122
+ const matcher = new RegExp(
123
+ "\\.target\\(\\s*['\"]" + escaped(target) + "['\"]\\s*\\)",
124
+ );
125
+ const candidates = functions.filter((fn) => matcher.test(fn));
126
+ check(candidates.length === 1, "no unique supported build recipe");
127
+ let fn = candidates[0];
128
+ check(
129
+ !/\.(?:run|replace|writeFile|copyFile|rename)\s*\(|\bfs\.|\bpatch\b/.test(
130
+ fn,
131
+ ),
132
+ "patched or custom build recipe",
133
+ );
134
+ fn = fn.split("\n}", 1)[0] + "\n}";
135
+ check(fn.length <= 16384, "recipe size limit exceeded");
136
+ const clean = fn.replace(
137
+ /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\/\*.*?\*\/|\/\/[^\n]*/gs,
138
+ (value) => (value.startsWith("/*") || value.startsWith("//") ? " " : value),
139
+ );
140
+ const shape =
141
+ /^export async function \w+\(\s*task\s*,\s*opts\s*\)\s*\{\s*await\s+task\s*\.source\(\s*relative\(\s*__dirname\s*,\s*require\.resolve\(\s*(['"])([^'"\\\r\n]+)\1\s*\)\s*\)\s*\)\s*\.ncc\(\s*\{(.*?)\}\s*\)\s*\.target\(\s*(['"])([^'"\\\r\n]+)\4\s*\)\s*;?\s*\}(?![\s\S])/s;
142
+ const match = clean.trim().match(shape);
143
+ check(match, "unsupported recipe operations");
144
+ const options = nccOptions(match[3]),
145
+ source = match[2];
146
+ check(
147
+ options.packageName === manifestName && match[5] === target,
148
+ "unsupported recipe identity",
149
+ );
150
+ check(
151
+ /^(?:@[A-Za-z0-9_.-]+\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*(?![\s\S])/.test(
152
+ source,
153
+ ) && safePath(source),
154
+ "unsafe recipe source",
155
+ );
156
+ const mappings = options.externals || {};
157
+ check(
158
+ !Object.keys(mappings).some((name) =>
159
+ [packageRoot(source), manifestName].includes(packageRoot(name)),
160
+ ),
161
+ "recipe externalizes its own source package",
162
+ );
163
+ return { request: packageRoot(source), externals: mappings, sha256: sha(fn) };
164
+ }
165
+ function npmSpec(name, spec) {
166
+ check(
167
+ typeof spec === "string" &&
168
+ !/git|github:|https?:|file:|link:|workspace:|patch:/.test(spec),
169
+ "non-registry or patched dependency",
170
+ );
171
+ if (spec.startsWith("npm:")) {
172
+ const match = spec.match(/^npm:((?:@[^/]+\/)?[^@]+)@(.+)(?![\s\S])/);
173
+ check(match, "unsupported npm alias");
174
+ return [match[1], match[2]];
175
+ }
176
+ return [name, spec];
177
+ }
178
+ function exactResolution(name, spec, locator) {
179
+ const [actual, requested] = npmSpec(name, spec);
180
+ check(
181
+ typeof locator === "string" && !/patch|github|http/.test(locator),
182
+ "patched or non-registry resolution",
183
+ );
184
+ let value = locator.replace(/^\/+/, "");
185
+ for (const prefix of [actual + "@", actual + "/"])
186
+ if (value.startsWith(prefix)) {
187
+ value = value.slice(prefix.length);
188
+ break;
189
+ }
190
+ const version = value.split("(")[0].split("_")[0];
191
+ check(SEMVER.test(version), "resolution is not an exact npm version");
192
+ check(
193
+ !SEMVER.test(requested) || version === requested,
194
+ "manifest/lock exact version mismatch",
195
+ );
196
+ return [actual, version];
197
+ }
198
+ function registryIntegrity(lock, name, version) {
199
+ const candidates = [];
200
+ for (const [key, entry] of Object.entries(lock.packages || {})) {
201
+ let identity;
202
+ try {
203
+ identity = exactResolution(name, version, key);
204
+ } catch (error) {
205
+ if (error instanceof Unresolved) continue;
206
+ throw error;
207
+ }
208
+ if (
209
+ equal(identity, [name, version]) &&
210
+ entry &&
211
+ typeof entry === "object"
212
+ ) {
213
+ const resolution = entry.resolution || {};
214
+ check(
215
+ resolution && typeof resolution === "object",
216
+ "unsupported pnpm package resolution",
217
+ );
218
+ if (resolution.tarball) {
219
+ const url = new URL(resolution.tarball);
220
+ check(
221
+ url.protocol === "https:" && url.hostname === "registry.npmjs.org",
222
+ "non-registry pnpm tarball",
223
+ );
224
+ }
225
+ candidates.push(resolution.integrity);
226
+ }
227
+ }
228
+ check(
229
+ candidates.length &&
230
+ new Set(candidates).size === 1 &&
231
+ typeof candidates[0] === "string",
232
+ "no unique historical package integrity",
233
+ );
234
+ return candidates[0];
235
+ }
236
+ function lockResolution(git, commit, name) {
237
+ const manifests = {};
238
+ for (const [importer, file] of [
239
+ ["packages/next", "packages/next/package.json"],
240
+ [".", "package.json"],
241
+ ]) {
242
+ const raw = git.show(commit, file);
243
+ if (raw) {
244
+ const manifest = json(raw),
245
+ values = ["dependencies", "devDependencies", "optionalDependencies"]
246
+ .filter((k) => Object.hasOwn(manifest[k] || {}, name))
247
+ .map((k) => manifest[k][name]);
248
+ check(new Set(values).size <= 1, "ambiguous dependency manifest");
249
+ if (values.length) manifests[importer] = values[0];
250
+ }
251
+ }
252
+ check(
253
+ Object.keys(manifests).length,
254
+ "build dependency absent from historical manifests",
255
+ );
256
+ const importer = Object.hasOwn(manifests, "packages/next")
257
+ ? "packages/next"
258
+ : ".",
259
+ spec = manifests[importer];
260
+ const [actual] = npmSpec(name, spec);
261
+ const raw = git.show(commit, "pnpm-lock.yaml");
262
+ if (raw) {
263
+ const YAML = require("yaml");
264
+ const lock = YAML.parse(String(raw), {
265
+ maxAliasCount: 100,
266
+ uniqueKeys: true,
267
+ });
268
+ check(lock && typeof lock === "object", "invalid pnpm lockfile");
269
+ check(
270
+ !Object.keys(lock.patchedDependencies || {}).some(
271
+ (key) => key === actual || key.startsWith(actual + "@"),
272
+ ),
273
+ "historical pnpm patch",
274
+ );
275
+ const selected = lock.importers?.[importer] || {},
276
+ values = ["dependencies", "devDependencies", "optionalDependencies"]
277
+ .filter((k) => Object.hasOwn(selected[k] || {}, name))
278
+ .map((k) => selected[k][name]);
279
+ check(values.length === 1, "no unique historical pnpm importer resolution");
280
+ let value = values[0];
281
+ if (value && typeof value === "object") {
282
+ check(value.specifier === spec, "historical pnpm specifier mismatch");
283
+ value = value.version;
284
+ } else
285
+ check(
286
+ selected.specifiers?.[name] === spec,
287
+ "historical pnpm specifier mismatch",
288
+ );
289
+ const [resolved, version] = exactResolution(name, spec, value);
290
+ return [
291
+ resolved,
292
+ version,
293
+ "pnpm-lock.yaml",
294
+ {
295
+ locator: String(value),
296
+ integrity: registryIntegrity(lock, resolved, version),
297
+ },
298
+ ];
299
+ }
300
+ const yarn = git.show(commit, "yarn.lock");
301
+ check(yarn, "no supported historical lockfile");
302
+ const wanted = name + "@" + spec,
303
+ matches = [];
304
+ for (const block of String(yarn).split(/\n(?=\S)/)) {
305
+ const rows = block.split("\n");
306
+ if (!rows[0].endsWith(":") || rows[0].startsWith("#")) continue;
307
+ const keys = rows[0]
308
+ .slice(0, -1)
309
+ .split(", ")
310
+ .map((key) => key.trim().replace(/^["']|["'](?![\s\S])/g, ""));
311
+ if (!keys.includes(wanted)) continue;
312
+ const version = block.match(/^ version "([^"\n]+)"$/m),
313
+ source = block.match(/^ resolved "([^"\n]+)"$/m),
314
+ integrity = block.match(/^ integrity ([^\s]+)$/m);
315
+ if (
316
+ version &&
317
+ source &&
318
+ /^https:\/\/registry\.(?:yarnpkg\.com|npmjs\.org)\//.test(source[1])
319
+ ) {
320
+ let digest = integrity
321
+ ? integrity[1].replace(/^"|"(?![\s\S])/g, "")
322
+ : null;
323
+ const fragment = new URL(source[1]).hash.slice(1);
324
+ if (!digest && /^[a-f0-9]{40}(?![\s\S])/.test(fragment))
325
+ digest = "sha1-" + Buffer.from(fragment, "hex").toString("base64");
326
+ if (digest) matches.push([version[1], source[1], digest]);
327
+ }
328
+ }
329
+ check(matches.length === 1, "no unique registry-backed yarn resolution");
330
+ const [resolved, version] = exactResolution(name, spec, matches[0][0]);
331
+ return [
332
+ resolved,
333
+ version,
334
+ "yarn.lock",
335
+ { locator: matches[0][1], integrity: matches[0][2] },
336
+ ];
337
+ }
338
+ function compiledComponents(files) {
339
+ const manifests = Object.keys(files)
340
+ .filter(
341
+ (p) => p.startsWith("dist/compiled/") && p.endsWith("/package.json"),
342
+ )
343
+ .sort();
344
+ return manifests.flatMap((file) => {
345
+ const manifest = json(files[file]);
346
+ if (![undefined, null, "", "UNKNOWN"].includes(manifest.version)) return [];
347
+ const prefix = file.slice(0, -"/package.json".length);
348
+ const contents = manifests.some(
349
+ (p) => p !== file && p.startsWith(prefix + "/"),
350
+ )
351
+ ? null
352
+ : Object.fromEntries(
353
+ Object.entries(files)
354
+ .filter(([p]) => p.startsWith(prefix + "/"))
355
+ .map(([p, value]) => [p.slice(prefix.length + 1), value]),
356
+ );
357
+ return [[prefix, manifest, contents]];
358
+ });
359
+ }
360
+ async function resolveComponent(
361
+ git,
362
+ releaseCommit,
363
+ location,
364
+ manifest,
365
+ files,
366
+ verifyRegistry,
367
+ ) {
368
+ const name = manifest.name;
369
+ check(
370
+ typeof name === "string" && name && files,
371
+ "invalid or nested compiled package",
372
+ );
373
+ const executables = Object.keys(files)
374
+ .filter((p) => /\.(js|cjs|mjs|wasm|node)(?![\s\S])/.test(p))
375
+ .sort();
376
+ check(executables.length, "no executable for provenance");
377
+ const relative = location.slice("dist/".length),
378
+ candidates = ["packages/next/src/" + relative, "packages/next/" + relative];
379
+ const sourcePath = candidates.find((p) =>
380
+ equal(git.tree(releaseCommit, p), files),
381
+ );
382
+ check(sourcePath, "official package differs from release Git tree");
383
+ const representative =
384
+ executables.find((p) => p.endsWith(".js")) || executables[0];
385
+ const code = (values) =>
386
+ Object.fromEntries(
387
+ Object.entries(values).filter(
388
+ ([p]) =>
389
+ !["package.json", "LICENSE", "LICENSE.md", "LICENSE.txt"].includes(p),
390
+ ),
391
+ );
392
+ for (const [commit, filename] of git.history(
393
+ releaseCommit,
394
+ sourcePath + "/" + representative,
395
+ )) {
396
+ if (!filename.endsWith("/" + representative)) continue;
397
+ const historicalPath = filename.slice(0, -representative.length - 1),
398
+ tree = git.tree(commit, historicalPath);
399
+ if (!equal(code(tree), code(files))) continue;
400
+ const parents = String(git.call("rev-list", "--parents", "-n", "1", commit))
401
+ .trim()
402
+ .split(/\s+/)
403
+ .slice(1);
404
+ if (parents.length !== 1) continue;
405
+ const previous = git.show(parents[0], filename);
406
+ if (previous && previous.equals(files[representative])) continue;
407
+ const taskfile = git.show(commit, "packages/next/taskfile.js");
408
+ if (!taskfile) continue;
409
+ const recipe = recipeDetails(
410
+ String(taskfile),
411
+ historicalPath.slice("packages/next/".length),
412
+ name,
413
+ );
414
+ const [resolvedName, version, lockfile, resolution] = lockResolution(
415
+ git,
416
+ commit,
417
+ recipe.request,
418
+ );
419
+ check(
420
+ resolvedName === name,
421
+ "compiled manifest differs from resolved npm package",
422
+ );
423
+ await verifyRegistry(resolvedName, version, resolution);
424
+ const resolutionText = JSON.stringify(
425
+ Object.fromEntries(Object.entries(resolution).sort()),
426
+ null,
427
+ 0,
428
+ )
429
+ .replace(/":/g, '": ')
430
+ .replace(/,"/g, ', "');
431
+ return {
432
+ path: location,
433
+ name,
434
+ version,
435
+ purl: purl(name, version),
436
+ files: Object.fromEntries(
437
+ Object.keys(files)
438
+ .sort()
439
+ .map((p) => [p, sha(files[p])]),
440
+ ),
441
+ evidence: {
442
+ commit,
443
+ sourcePath: historicalPath,
444
+ lockfile,
445
+ resolution: resolutionText,
446
+ recipe: {
447
+ kind: "direct-ncc",
448
+ sha256: recipe.sha256,
449
+ externals: recipe.externals,
450
+ },
451
+ },
452
+ };
453
+ }
454
+ reject("no proven historical rebuild within history limit");
455
+ }
456
+ module.exports = {
457
+ Unresolved,
458
+ packageRoot,
459
+ nccOptions,
460
+ recipeDetails,
461
+ npmSpec,
462
+ exactResolution,
463
+ registryIntegrity,
464
+ lockResolution,
465
+ compiledComponents,
466
+ resolveComponent,
467
+ };