@openbrf/plugin-sdk 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/LICENSE-EXCEPTION.md +28 -0
- package/dist/action-schema.d.ts +42 -0
- package/dist/action-schema.d.ts.map +1 -0
- package/dist/action-schema.js +64 -0
- package/dist/action-schema.js.map +1 -0
- package/dist/actions.d.ts +199 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +68 -0
- package/dist/actions.js.map +1 -0
- package/dist/api-version.d.ts +23 -0
- package/dist/api-version.d.ts.map +1 -0
- package/dist/api-version.js +27 -0
- package/dist/api-version.js.map +1 -0
- package/dist/catalog.d.ts +315 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +203 -0
- package/dist/catalog.js.map +1 -0
- package/dist/define-plugin.d.ts +14 -0
- package/dist/define-plugin.d.ts.map +1 -0
- package/dist/define-plugin.js +15 -0
- package/dist/define-plugin.js.map +1 -0
- package/dist/findings.d.ts +67 -0
- package/dist/findings.d.ts.map +1 -0
- package/dist/findings.js +57 -0
- package/dist/findings.js.map +1 -0
- package/dist/host.d.ts +208 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +41 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/integrity.d.ts +38 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +105 -0
- package/dist/integrity.js.map +1 -0
- package/dist/manifest.d.ts +392 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +324 -0
- package/dist/manifest.js.map +1 -0
- package/dist/module.d.ts +35 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +2 -0
- package/dist/module.js.map +1 -0
- package/dist/package-check.d.ts +66 -0
- package/dist/package-check.d.ts.map +1 -0
- package/dist/package-check.js +755 -0
- package/dist/package-check.js.map +1 -0
- package/dist/permissions.d.ts +65 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +69 -0
- package/dist/permissions.js.map +1 -0
- package/dist/settings-schema.d.ts +95 -0
- package/dist/settings-schema.d.ts.map +1 -0
- package/dist/settings-schema.js +219 -0
- package/dist/settings-schema.js.map +1 -0
- package/dist/standard-schema.d.ts +43 -0
- package/dist/standard-schema.d.ts.map +1 -0
- package/dist/standard-schema.js +2 -0
- package/dist/standard-schema.js.map +1 -0
- package/package.json +55 -3
- package/src/action-schema.ts +86 -0
- package/src/actions.ts +247 -0
- package/src/api-version.ts +28 -0
- package/src/catalog.ts +236 -0
- package/src/define-plugin.ts +18 -0
- package/src/findings.ts +71 -0
- package/src/host.ts +240 -0
- package/src/index.ts +123 -0
- package/src/integrity.ts +125 -0
- package/src/manifest.ts +385 -0
- package/src/module.ts +38 -0
- package/src/package-check.ts +894 -0
- package/src/permissions.ts +75 -0
- package/src/settings-schema.ts +272 -0
- package/src/standard-schema.ts +39 -0
- package/README.md +0 -3
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
import {
|
|
2
|
+
dependencyNames,
|
|
3
|
+
isEmptyDependencyField,
|
|
4
|
+
parsePluginPackage,
|
|
5
|
+
RUNTIME_DEPENDENCY_FIELDS,
|
|
6
|
+
} from "./manifest.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The checks a plugin package passes before it is listed.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is something an instance would otherwise find out after the
|
|
12
|
+
* board had consented and the application had restarted: a dependency the
|
|
13
|
+
* installer cannot fetch without a registry, a bundle reaching for a module it
|
|
14
|
+
* cannot resolve from the data volume, an entry the loader cannot find. Run
|
|
15
|
+
* against the packed tarball rather than the working tree, it answers whether
|
|
16
|
+
* what is published is installable, which is what a plugin's own CI and the
|
|
17
|
+
* catalog's check both need to know.
|
|
18
|
+
*
|
|
19
|
+
* It answers nothing about whether the package is safe. A plugin runs in the
|
|
20
|
+
* host's process with its privileges (ADR 0003), every Node built-in is open
|
|
21
|
+
* to it, and code has more ways to load a module than any reading of the
|
|
22
|
+
* source can follow. A package that passes installs and loads; what it does
|
|
23
|
+
* once loaded is for a person reading its code before it is listed.
|
|
24
|
+
*
|
|
25
|
+
* Pure: the caller reads the package and hands its contents in, so the same
|
|
26
|
+
* function serves a script, a test and a check that has the archive in
|
|
27
|
+
* memory.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Packages a plugin must share with the host rather than carry its own copy of.
|
|
32
|
+
*
|
|
33
|
+
* Two reasons a package belongs here, and they are different.
|
|
34
|
+
*
|
|
35
|
+
* The Nest packages hold process-wide state - a DI container, a metadata
|
|
36
|
+
* registry - so a second copy is not a duplicate but a second and disconnected
|
|
37
|
+
* system: decorators from one are invisible to the other.
|
|
38
|
+
*
|
|
39
|
+
* zod holds no state at all, and is here for a reason about identity rather
|
|
40
|
+
* than about state. An action's input and output schemas cross from the plugin
|
|
41
|
+
* into the host, which converts them to the JSON Schema a caller is published
|
|
42
|
+
* and validates against them on every call. A schema built by a second copy
|
|
43
|
+
* carries that copy's internals, so the host's realm check refuses it and its
|
|
44
|
+
* converter could not read it. What matters here is therefore which module the
|
|
45
|
+
* object came from, not what that module remembers.
|
|
46
|
+
*
|
|
47
|
+
* With Node's built-in modules, they are also the only modules a server bundle
|
|
48
|
+
* may require: an installed plugin sits on the data volume, where nothing but
|
|
49
|
+
* these, bridged in from the host, and the built-ins can be resolved.
|
|
50
|
+
*/
|
|
51
|
+
export const HOST_SHARED_PACKAGES: readonly string[] = [
|
|
52
|
+
"@nestjs/common",
|
|
53
|
+
"@nestjs/core",
|
|
54
|
+
"zod",
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** A plugin package as the check reads it. */
|
|
58
|
+
export interface PluginPackageContents {
|
|
59
|
+
/** The package's `package.json`, parsed. */
|
|
60
|
+
packageJson: unknown;
|
|
61
|
+
/**
|
|
62
|
+
* Every file in the package, relative to its root and separated by forward
|
|
63
|
+
* slashes: `package.json`, `dist/server.cjs`, `locales/sv.json`.
|
|
64
|
+
*/
|
|
65
|
+
files: readonly string[];
|
|
66
|
+
/** The source of the declared server entry, or null when there is none. */
|
|
67
|
+
serverBundle: string | null;
|
|
68
|
+
/** The two locale files, parsed; a missing file is left undefined. */
|
|
69
|
+
locales: { sv?: unknown; en?: unknown };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Words after which a `/` opens a regular expression rather than dividing.
|
|
74
|
+
*
|
|
75
|
+
* After any other word, a number, a string or a closing bracket it divides.
|
|
76
|
+
* Getting this wrong only matters inside the one line the misread literal
|
|
77
|
+
* sits on, because a string never runs past the end of its line.
|
|
78
|
+
*/
|
|
79
|
+
const KEYWORDS_BEFORE_EXPRESSION: ReadonlySet<string> = new Set([
|
|
80
|
+
"await",
|
|
81
|
+
"case",
|
|
82
|
+
"delete",
|
|
83
|
+
"do",
|
|
84
|
+
"else",
|
|
85
|
+
"in",
|
|
86
|
+
"instanceof",
|
|
87
|
+
"new",
|
|
88
|
+
"of",
|
|
89
|
+
"return",
|
|
90
|
+
"throw",
|
|
91
|
+
"typeof",
|
|
92
|
+
"void",
|
|
93
|
+
"yield",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The words in KEYWORDS_BEFORE_EXPRESSION that a script may also use as a
|
|
98
|
+
* plain name, so that a `/` after one may divide: `await / 2`. After the
|
|
99
|
+
* others, such as `return`, a `/` always opens a regular expression.
|
|
100
|
+
*/
|
|
101
|
+
const KEYWORDS_THAT_MAY_BE_NAMES: ReadonlySet<string> = new Set([
|
|
102
|
+
"await",
|
|
103
|
+
"of",
|
|
104
|
+
"yield",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Tokens after which a `/` divides. "postfix" stands for a `++` or `--` that
|
|
109
|
+
* follows its operand on the same line; before its operand, as in
|
|
110
|
+
* `++/a/.lastIndex`, a `/` opens a regular expression.
|
|
111
|
+
*/
|
|
112
|
+
const TOKENS_BEFORE_DIVISION: ReadonlySet<string> = new Set([
|
|
113
|
+
"literal",
|
|
114
|
+
"postfix",
|
|
115
|
+
")",
|
|
116
|
+
"]",
|
|
117
|
+
"}",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Tokens after which a method name can stand, in a class body or an object
|
|
122
|
+
* literal: `{ require(name) { ... } }`, `static require(name) { ... }`.
|
|
123
|
+
*/
|
|
124
|
+
const TOKENS_BEFORE_METHOD_NAME: ReadonlySet<string> = new Set([
|
|
125
|
+
"{",
|
|
126
|
+
",",
|
|
127
|
+
";",
|
|
128
|
+
"}",
|
|
129
|
+
"*",
|
|
130
|
+
"async",
|
|
131
|
+
"get",
|
|
132
|
+
"set",
|
|
133
|
+
"static",
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
const IDENTIFIER_START = /[\p{ID_Start}$_]/u;
|
|
137
|
+
const IDENTIFIER_PART = /[\p{ID_Continue}$\u200c\u200d]/u;
|
|
138
|
+
|
|
139
|
+
/** A `\uXXXX` escape, or a `\u{...}` one, which may pad its digits with zeros. */
|
|
140
|
+
const UNICODE_ESCAPE = /\\u(?:([0-9a-fA-F]{4})|\{([0-9a-fA-F]+)\})/y;
|
|
141
|
+
|
|
142
|
+
/** The characters that end a line in JavaScript, and so a line comment. */
|
|
143
|
+
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
144
|
+
const NEXT_LINE_TERMINATOR = new RegExp(LINE_TERMINATOR.source, "g");
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Punctuators of more than one character that the scanner tells apart: `++`
|
|
148
|
+
* and `--` because a `/` after a postfix one divides, `?.` because it reaches
|
|
149
|
+
* a property as `.` does, and `...` because it spreads rather than reaching
|
|
150
|
+
* one.
|
|
151
|
+
*/
|
|
152
|
+
const LONG_PUNCTUATORS = ["...", "?.", "++", "--"] as const;
|
|
153
|
+
|
|
154
|
+
/** A call to require whose closing parenthesis the scanner has yet to reach. */
|
|
155
|
+
interface PendingCall {
|
|
156
|
+
/** The specifier, or null when the argument is not one plain literal. */
|
|
157
|
+
specifier: string | null;
|
|
158
|
+
/** Whether a method name could stand where the word is. */
|
|
159
|
+
mayBeMethod: boolean;
|
|
160
|
+
/** How many `/` that could be misread the scanner had read at the word. */
|
|
161
|
+
slashes: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every problem that would stop a package installing or loading, one English
|
|
166
|
+
* sentence each, for an author's terminal. Empty when there is none.
|
|
167
|
+
*/
|
|
168
|
+
export function pluginPackageProblems(
|
|
169
|
+
contents: PluginPackageContents,
|
|
170
|
+
): readonly string[] {
|
|
171
|
+
const problems: string[] = [];
|
|
172
|
+
const packageJson = asRecord(contents.packageJson);
|
|
173
|
+
|
|
174
|
+
// The dependency fields get sentences of their own below, which name what to
|
|
175
|
+
// do instead, so the manifest is read without them rather than reported twice.
|
|
176
|
+
const parsed = parsePluginPackage(withoutRuntimeDependencies(packageJson));
|
|
177
|
+
if (!parsed.ok) {
|
|
178
|
+
for (const issue of parsed.issues) {
|
|
179
|
+
problems.push(`The manifest in package.json is invalid at ${issue}.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
problems.push(...dependencyProblems(packageJson));
|
|
184
|
+
|
|
185
|
+
if (parsed.ok) {
|
|
186
|
+
const entries = parsed.value.openbrf.entry;
|
|
187
|
+
const files = new Set(contents.files.map(normalizedPath));
|
|
188
|
+
for (const [kind, declared] of [
|
|
189
|
+
["server", entries.server],
|
|
190
|
+
["client", entries.client],
|
|
191
|
+
] as const) {
|
|
192
|
+
if (declared !== undefined && !files.has(normalizedPath(declared))) {
|
|
193
|
+
problems.push(
|
|
194
|
+
`The manifest declares ${declared} as the ${kind} entry, and the package does not contain it.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (entries.server !== undefined && contents.serverBundle !== null) {
|
|
200
|
+
problems.push(...serverBundleProblems(contents.serverBundle));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
problems.push(...localeProblems(contents));
|
|
205
|
+
|
|
206
|
+
return problems;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function dependencyProblems(
|
|
210
|
+
packageJson: Record<string, unknown> | null,
|
|
211
|
+
): string[] {
|
|
212
|
+
if (packageJson === null) {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const problems: string[] = [];
|
|
217
|
+
for (const field of RUNTIME_DEPENDENCY_FIELDS) {
|
|
218
|
+
const value = packageJson[field];
|
|
219
|
+
if (
|
|
220
|
+
value !== undefined &&
|
|
221
|
+
dependencyNames(value).length === 0 &&
|
|
222
|
+
!isEmptyDependencyField(value)
|
|
223
|
+
) {
|
|
224
|
+
problems.push(
|
|
225
|
+
`package.json gives ${field} as something other than a map or a list of packages. A plugin declares no runtime dependencies: leave the field out, or leave it empty.`,
|
|
226
|
+
);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// A host package gets its own sentence below, which says what to do
|
|
230
|
+
// instead; naming it here as well would report one entry twice.
|
|
231
|
+
const names = dependencyNames(packageJson[field]).filter(
|
|
232
|
+
(name) => !HOST_SHARED_PACKAGES.includes(name),
|
|
233
|
+
);
|
|
234
|
+
if (names.length > 0) {
|
|
235
|
+
problems.push(
|
|
236
|
+
`package.json lists ${names.join(", ")} under ${field}. A plugin declares no runtime dependencies: the installer's npm would fetch them from a registry, which an instance never contacts.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const host of HOST_SHARED_PACKAGES) {
|
|
242
|
+
for (const field of RUNTIME_DEPENDENCY_FIELDS) {
|
|
243
|
+
if (dependencyNames(packageJson[field]).includes(host)) {
|
|
244
|
+
problems.push(
|
|
245
|
+
`package.json lists ${host} under ${field}. The host shares its own copy, so declare it under peerDependencies and never bundle it.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return problems;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function serverBundleProblems(source: string): string[] {
|
|
255
|
+
const problems: string[] = [];
|
|
256
|
+
const { specifiers, computed } = requireCalls(source);
|
|
257
|
+
|
|
258
|
+
const foreign = [
|
|
259
|
+
...new Set(
|
|
260
|
+
specifiers.filter(
|
|
261
|
+
(specifier) =>
|
|
262
|
+
!isHostPackage(specifier) && !isNodeBuiltinModule(specifier),
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
];
|
|
266
|
+
if (foreign.length > 0) {
|
|
267
|
+
problems.push(
|
|
268
|
+
`The server bundle requires ${foreign.join(", ")}. Its only externals may be the host packages (${HOST_SHARED_PACKAGES.join(", ")}) and Node's built-in modules; an installed plugin cannot resolve anything else.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (computed) {
|
|
273
|
+
problems.push(
|
|
274
|
+
"The server bundle calls require with something other than a string literal, so what it loads cannot be checked.",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!source.includes("exports.createPlugin")) {
|
|
279
|
+
problems.push(
|
|
280
|
+
"The server bundle does not assign exports.createPlugin. The host loads it with require and reads that export.",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return problems;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Whether a specifier names a host package or a path inside one.
|
|
289
|
+
*
|
|
290
|
+
* `zod/v4` resolves through the same bridge as `zod`: the bridge makes the
|
|
291
|
+
* host's node_modules a fallback for every lookup, not for a list of names.
|
|
292
|
+
*/
|
|
293
|
+
function isHostPackage(specifier: string): boolean {
|
|
294
|
+
return HOST_SHARED_PACKAGES.some(
|
|
295
|
+
(host) => specifier === host || specifier.startsWith(`${host}/`),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Every call to require in a bundle: the specifiers written as plain string
|
|
301
|
+
* literals, and whether any call's argument is something else.
|
|
302
|
+
*
|
|
303
|
+
* One pass finds the calls and reads each argument, so a spelling of the call
|
|
304
|
+
* is either seen by both questions or by neither, and a call the literal
|
|
305
|
+
* reading does not recognise is a computed one rather than no call at all.
|
|
306
|
+
*
|
|
307
|
+
* A scanner rather than a regular expression, for two reasons. The bundle is
|
|
308
|
+
* a file someone outside the project submits, and a pattern that lets
|
|
309
|
+
* comments stand between `require` and its parenthesis can be made to
|
|
310
|
+
* backtrack for hours; this reads each character a bounded number of times.
|
|
311
|
+
* And it knows where code is: `require(` inside a comment, a string, template
|
|
312
|
+
* text or a regular expression is not a call, and neither is a method of that
|
|
313
|
+
* name on some other object. `module.require` is the one exception, since it
|
|
314
|
+
* is the same loader under another name.
|
|
315
|
+
*
|
|
316
|
+
* A method or accessor named require, `{ require(name) { ... } }`, reads like
|
|
317
|
+
* a call up to its closing parenthesis, and only the `{` after that tells the
|
|
318
|
+
* two apart. So a call is recorded when the scanner reaches its closing
|
|
319
|
+
* parenthesis rather than when it reads the word, which keeps the reading a
|
|
320
|
+
* single pass. Every doubt goes to the call, since a check that fails a
|
|
321
|
+
* method is better than one that passes a call:
|
|
322
|
+
*
|
|
323
|
+
* - The word must stand where a method name can, after `{`, `,`, `;`, `}`,
|
|
324
|
+
* `static`, `get`, `set`, `async` or `*`. In
|
|
325
|
+
* `class A extends require("x") {}` the `{` opens the class body.
|
|
326
|
+
* - The `{` must be on the same line: there a line break may be the end of a
|
|
327
|
+
* statement.
|
|
328
|
+
* - No `/` that the scanner may have misread may stand between the
|
|
329
|
+
* parentheses, since a `)` inside a misread regular expression or division
|
|
330
|
+
* would close the call early. That is every `/` read as a division, and a
|
|
331
|
+
* regular expression read after a word that may be a name, as in
|
|
332
|
+
* `await / 2`. A regular expression after a punctuator or a reserved word,
|
|
333
|
+
* as in `require(pattern = /x/) {}`, cannot be a division.
|
|
334
|
+
*/
|
|
335
|
+
function requireCalls(source: string): {
|
|
336
|
+
specifiers: string[];
|
|
337
|
+
computed: boolean;
|
|
338
|
+
} {
|
|
339
|
+
const specifiers: string[] = [];
|
|
340
|
+
let computed = false;
|
|
341
|
+
const record = (call: PendingCall | undefined): void => {
|
|
342
|
+
if (call === undefined) {
|
|
343
|
+
return;
|
|
344
|
+
} else if (call.specifier === null) {
|
|
345
|
+
computed = true;
|
|
346
|
+
} else {
|
|
347
|
+
specifiers.push(call.specifier);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A require whose parenthesis is the next one the scanner reaches, and the
|
|
353
|
+
* open parentheses, each with the require it opened, if it opened one.
|
|
354
|
+
*/
|
|
355
|
+
let awaiting: PendingCall | undefined;
|
|
356
|
+
const parentheses: (PendingCall | undefined)[] = [];
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* How many `/` the scanner has read that could be misread: divisions, and
|
|
360
|
+
* regular expressions after a word.
|
|
361
|
+
*/
|
|
362
|
+
let slashes = 0;
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The last three words or punctuators seen, newest last, and whether a line
|
|
366
|
+
* has ended since the newest, in a comment or not.
|
|
367
|
+
*/
|
|
368
|
+
const recent: string[] = [];
|
|
369
|
+
let lineBreak = false;
|
|
370
|
+
const remember = (token: string): void => {
|
|
371
|
+
lineBreak = false;
|
|
372
|
+
recent.push(token);
|
|
373
|
+
if (recent.length > 3) {
|
|
374
|
+
recent.shift();
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
const previous = (back: number): string | undefined => recent.at(-back);
|
|
378
|
+
|
|
379
|
+
/** Brace depth, and the depths at which a template's `${` was opened. */
|
|
380
|
+
let depth = 0;
|
|
381
|
+
const substitutions: number[] = [];
|
|
382
|
+
|
|
383
|
+
/** Skips template text; code resumes after it, inside a `${` or not. */
|
|
384
|
+
const templateText = (from: number): number => {
|
|
385
|
+
const end = afterTemplateText(source, from);
|
|
386
|
+
if (source[end - 1] === "{") {
|
|
387
|
+
depth += 1;
|
|
388
|
+
substitutions.push(depth);
|
|
389
|
+
remember("{");
|
|
390
|
+
} else {
|
|
391
|
+
remember("literal");
|
|
392
|
+
}
|
|
393
|
+
return end;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
let index = 0;
|
|
397
|
+
while (index < source.length) {
|
|
398
|
+
const char = source[index] as string;
|
|
399
|
+
const next = source[index + 1];
|
|
400
|
+
|
|
401
|
+
if (/\s/.test(char)) {
|
|
402
|
+
lineBreak ||= LINE_TERMINATOR.test(char);
|
|
403
|
+
index += 1;
|
|
404
|
+
} else if (char === "/" && (next === "/" || next === "*")) {
|
|
405
|
+
const end = afterComment(source, index);
|
|
406
|
+
lineBreak ||= LINE_TERMINATOR.test(source.slice(index, end));
|
|
407
|
+
index = end;
|
|
408
|
+
} else if (char === '"' || char === "'") {
|
|
409
|
+
index = afterString(source, index).end;
|
|
410
|
+
remember("literal");
|
|
411
|
+
} else if (char === "`") {
|
|
412
|
+
index = templateText(index + 1);
|
|
413
|
+
} else if (char === "/" && startsExpression(previous)) {
|
|
414
|
+
// After a name the guess can be wrong: `await / 2` divides.
|
|
415
|
+
if (KEYWORDS_THAT_MAY_BE_NAMES.has(previous(1) ?? "")) {
|
|
416
|
+
slashes += 1;
|
|
417
|
+
}
|
|
418
|
+
index = afterRegularExpression(source, index);
|
|
419
|
+
remember("literal");
|
|
420
|
+
} else if (char === "#" && startsIdentifier(source, index + 1)) {
|
|
421
|
+
// A private name, `#require`, which is never the loader.
|
|
422
|
+
const identifier = afterIdentifier(source, index + 1);
|
|
423
|
+
remember(`#${identifier.name}`);
|
|
424
|
+
index = identifier.end;
|
|
425
|
+
} else if (startsIdentifier(source, index)) {
|
|
426
|
+
const { name: word, end } = afterIdentifier(source, index);
|
|
427
|
+
// `typeof require` and `require.resolve` name the loader without
|
|
428
|
+
// calling it, so they leave nothing awaiting.
|
|
429
|
+
const argument =
|
|
430
|
+
word === "require" && isLoaderReference(previous)
|
|
431
|
+
? requireArgument(source, end)
|
|
432
|
+
: "not-a-call";
|
|
433
|
+
if (argument !== "not-a-call") {
|
|
434
|
+
awaiting = {
|
|
435
|
+
specifier: argument,
|
|
436
|
+
mayBeMethod: TOKENS_BEFORE_METHOD_NAME.has(previous(1) ?? ""),
|
|
437
|
+
slashes,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
remember(word);
|
|
441
|
+
index = end;
|
|
442
|
+
} else if (char === "(") {
|
|
443
|
+
parentheses.push(awaiting);
|
|
444
|
+
awaiting = undefined;
|
|
445
|
+
remember(char);
|
|
446
|
+
index += 1;
|
|
447
|
+
} else if (char === ")") {
|
|
448
|
+
const call = parentheses.pop();
|
|
449
|
+
const parameterList =
|
|
450
|
+
call !== undefined &&
|
|
451
|
+
call.mayBeMethod &&
|
|
452
|
+
call.slashes === slashes &&
|
|
453
|
+
opensBody(source, index + 1);
|
|
454
|
+
if (!parameterList) {
|
|
455
|
+
record(call);
|
|
456
|
+
}
|
|
457
|
+
remember(char);
|
|
458
|
+
index += 1;
|
|
459
|
+
} else if (/[0-9]/.test(char)) {
|
|
460
|
+
index += 1;
|
|
461
|
+
while (index < source.length && /[\w.]/.test(source[index] as string)) {
|
|
462
|
+
index += 1;
|
|
463
|
+
}
|
|
464
|
+
remember("literal");
|
|
465
|
+
} else if (char === "}" && substitutions.at(-1) === depth) {
|
|
466
|
+
// The end of a template's `${ }`: its text resumes here.
|
|
467
|
+
substitutions.pop();
|
|
468
|
+
depth -= 1;
|
|
469
|
+
index = templateText(index + 1);
|
|
470
|
+
} else {
|
|
471
|
+
if (char === "{") {
|
|
472
|
+
depth += 1;
|
|
473
|
+
} else if (char === "}") {
|
|
474
|
+
depth -= 1;
|
|
475
|
+
}
|
|
476
|
+
if (char === "/") {
|
|
477
|
+
slashes += 1;
|
|
478
|
+
}
|
|
479
|
+
const punctuator = longPunctuator(source, index) ?? char;
|
|
480
|
+
// `a++ / b` divides, `++/a/.lastIndex` does not: a postfix operator
|
|
481
|
+
// follows what could end an expression, on the same line. After a line
|
|
482
|
+
// break, `a\n++/a/.lastIndex` is two statements.
|
|
483
|
+
const postfix =
|
|
484
|
+
(punctuator === "++" || punctuator === "--") &&
|
|
485
|
+
!lineBreak &&
|
|
486
|
+
!startsExpression(previous);
|
|
487
|
+
remember(postfix ? "postfix" : punctuator);
|
|
488
|
+
index += punctuator.length;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// A call whose parenthesis is left open is still counted.
|
|
493
|
+
for (const call of parentheses) {
|
|
494
|
+
record(call);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return { specifiers, computed };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The multi-character punctuator starting at `from`, if there is one. */
|
|
501
|
+
function longPunctuator(source: string, from: number): string | undefined {
|
|
502
|
+
return LONG_PUNCTUATORS.find(
|
|
503
|
+
(punctuator) =>
|
|
504
|
+
source.startsWith(punctuator, from) &&
|
|
505
|
+
// `a ?.5 : b` is a conditional, not an optional chain.
|
|
506
|
+
!(punctuator === "?." && /[0-9]/.test(source[from + 2] ?? "")),
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Whether a `require` the scanner reached names the loader: a bare name, or
|
|
512
|
+
* `module.require`, but not `function require` or another object's method.
|
|
513
|
+
*/
|
|
514
|
+
function isLoaderReference(
|
|
515
|
+
previous: (back: number) => string | undefined,
|
|
516
|
+
): boolean {
|
|
517
|
+
const reachesProperty = (token: string | undefined): boolean =>
|
|
518
|
+
token === "." || token === "?.";
|
|
519
|
+
if (previous(1) === "function") {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
if (!reachesProperty(previous(1))) {
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
return previous(2) === "module" && !reachesProperty(previous(3));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Whether a `{` opening a function body follows the closing parenthesis just
|
|
530
|
+
* before `from` on the same line, which makes what that parenthesis closed a
|
|
531
|
+
* parameter list rather than a call's arguments.
|
|
532
|
+
*/
|
|
533
|
+
function opensBody(source: string, from: number): boolean {
|
|
534
|
+
const end = afterGap(source, from);
|
|
535
|
+
// Only the gap itself: searching on to the next line break would rescan the
|
|
536
|
+
// rest of a one-line bundle at every parenthesis.
|
|
537
|
+
return source[end] === "{" && !LINE_TERMINATOR.test(source.slice(from, end));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Whether a `/` after the newest token opens a regular expression. */
|
|
541
|
+
function startsExpression(
|
|
542
|
+
previous: (back: number) => string | undefined,
|
|
543
|
+
): boolean {
|
|
544
|
+
const token = previous(1);
|
|
545
|
+
if (token === undefined) {
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
if (TOKENS_BEFORE_DIVISION.has(token)) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
if (isWord(token)) {
|
|
552
|
+
// After a `.`, a word is a property name, whatever it is spelled:
|
|
553
|
+
// `o.of / 2` divides.
|
|
554
|
+
return (
|
|
555
|
+
!(previous(2) === "." || previous(2) === "?.") &&
|
|
556
|
+
KEYWORDS_BEFORE_EXPRESSION.has(token)
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Whether a remembered token is a name or a keyword, rather than a punctuator
|
|
564
|
+
* or one of the stand-ins "literal" and "postfix".
|
|
565
|
+
*/
|
|
566
|
+
function isWord(token: string | undefined): boolean {
|
|
567
|
+
return (
|
|
568
|
+
token !== undefined &&
|
|
569
|
+
!TOKENS_BEFORE_DIVISION.has(token) &&
|
|
570
|
+
(token.startsWith("#") || IDENTIFIER_START.test(characterAt(token, 0)))
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The character at `from`, which is two UTF-16 code units when it lies outside
|
|
576
|
+
* the Basic Multilingual Plane, as `𐐀` does; empty past the end.
|
|
577
|
+
*/
|
|
578
|
+
function characterAt(source: string, from: number): string {
|
|
579
|
+
const code = source.codePointAt(from);
|
|
580
|
+
return code === undefined ? "" : String.fromCodePoint(code);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Whether an identifier starts at `from`, spelled out or opening with a
|
|
585
|
+
* Unicode escape: `requ\u0069re` is the same name as `require`.
|
|
586
|
+
*/
|
|
587
|
+
function startsIdentifier(source: string, from: number): boolean {
|
|
588
|
+
return (
|
|
589
|
+
IDENTIFIER_START.test(characterAt(source, from)) ||
|
|
590
|
+
unicodeEscape(source, from) !== null
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Past the identifier starting at `from`, and its name with escapes read. */
|
|
595
|
+
function afterIdentifier(
|
|
596
|
+
source: string,
|
|
597
|
+
from: number,
|
|
598
|
+
): { end: number; name: string } {
|
|
599
|
+
// The name is sliced from the source up to the first escape, and built
|
|
600
|
+
// from there on.
|
|
601
|
+
let name: string | undefined;
|
|
602
|
+
let index = from;
|
|
603
|
+
while (index < source.length) {
|
|
604
|
+
const escape = unicodeEscape(source, index);
|
|
605
|
+
const char = characterAt(source, index);
|
|
606
|
+
if (escape !== null) {
|
|
607
|
+
name = (name ?? source.slice(from, index)) + escape.char;
|
|
608
|
+
index = escape.end;
|
|
609
|
+
} else if (
|
|
610
|
+
index === from ? IDENTIFIER_START.test(char) : IDENTIFIER_PART.test(char)
|
|
611
|
+
) {
|
|
612
|
+
if (name !== undefined) {
|
|
613
|
+
name += char;
|
|
614
|
+
}
|
|
615
|
+
index += char.length;
|
|
616
|
+
} else {
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return { end: index, name: name ?? source.slice(from, index) };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** The `\uXXXX` or `\u{X}` escape at `from`: the character, and past it. */
|
|
624
|
+
function unicodeEscape(
|
|
625
|
+
source: string,
|
|
626
|
+
from: number,
|
|
627
|
+
): { char: string; end: number } | null {
|
|
628
|
+
if (source[from] !== "\\") {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
UNICODE_ESCAPE.lastIndex = from;
|
|
632
|
+
const match = UNICODE_ESCAPE.exec(source);
|
|
633
|
+
const code =
|
|
634
|
+
match === null
|
|
635
|
+
? Number.NaN
|
|
636
|
+
: Number.parseInt(match[1] ?? match[2] ?? "", 16);
|
|
637
|
+
if (match === null || code > 0x10ffff) {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
return { char: String.fromCodePoint(code), end: from + match[0].length };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* What a `require` is called with, read from just after the word: the
|
|
645
|
+
* specifier when it is one plain string literal and nothing else, null when
|
|
646
|
+
* it is anything else, and "not-a-call" when no parenthesis follows. An
|
|
647
|
+
* optional call, `require?.(name)`, is a call.
|
|
648
|
+
*/
|
|
649
|
+
function requireArgument(source: string, from: number): string | null {
|
|
650
|
+
let index = afterGap(source, from);
|
|
651
|
+
if (longPunctuator(source, index) === "?.") {
|
|
652
|
+
index = afterGap(source, index + 2);
|
|
653
|
+
}
|
|
654
|
+
if (source[index] !== "(") {
|
|
655
|
+
return "not-a-call";
|
|
656
|
+
}
|
|
657
|
+
index = afterGap(source, index + 1);
|
|
658
|
+
const quote = source[index];
|
|
659
|
+
if (quote !== '"' && quote !== "'") {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
const literal = afterString(source, index);
|
|
663
|
+
if (!literal.plain || literal.end - index <= 2) {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
if (source[afterGap(source, literal.end)] !== ")") {
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
return source.slice(index + 1, literal.end - 1);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Past whitespace and comments, which may stand between parts of a call. */
|
|
673
|
+
function afterGap(source: string, from: number): number {
|
|
674
|
+
let index = from;
|
|
675
|
+
while (index < source.length) {
|
|
676
|
+
const char = source[index] as string;
|
|
677
|
+
if (/\s/.test(char)) {
|
|
678
|
+
index += 1;
|
|
679
|
+
} else if (
|
|
680
|
+
char === "/" &&
|
|
681
|
+
(source[index + 1] === "/" || source[index + 1] === "*")
|
|
682
|
+
) {
|
|
683
|
+
index = afterComment(source, index);
|
|
684
|
+
} else {
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return index;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Past the comment starting at `from`, which is a `/` followed by `/` or `*`. */
|
|
692
|
+
function afterComment(source: string, from: number): number {
|
|
693
|
+
if (source[from + 1] === "/") {
|
|
694
|
+
NEXT_LINE_TERMINATOR.lastIndex = from + 2;
|
|
695
|
+
const end = NEXT_LINE_TERMINATOR.exec(source);
|
|
696
|
+
return end === null ? source.length : end.index + 1;
|
|
697
|
+
}
|
|
698
|
+
const end = source.indexOf("*/", from + 2);
|
|
699
|
+
return end === -1 ? source.length : end + 2;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Past the string literal opening at `from`, and whether it was plain: no
|
|
704
|
+
* escape inside it, and closed on the line it opened on. An unclosed string
|
|
705
|
+
* ends at the line break, so a misread quote costs one line at most.
|
|
706
|
+
*
|
|
707
|
+
* Only CR and LF end a string: U+2028 and U+2029 may stand inside one.
|
|
708
|
+
*/
|
|
709
|
+
function afterString(
|
|
710
|
+
source: string,
|
|
711
|
+
from: number,
|
|
712
|
+
): { end: number; plain: boolean } {
|
|
713
|
+
const quote = source[from];
|
|
714
|
+
let plain = true;
|
|
715
|
+
let index = from + 1;
|
|
716
|
+
while (index < source.length) {
|
|
717
|
+
const char = source[index];
|
|
718
|
+
if (char === "\\") {
|
|
719
|
+
plain = false;
|
|
720
|
+
// A backslash before CRLF continues the string onto the next line.
|
|
721
|
+
index += source.startsWith("\r\n", index + 1) ? 3 : 2;
|
|
722
|
+
} else if (char === quote) {
|
|
723
|
+
return { end: index + 1, plain };
|
|
724
|
+
} else if (char === "\n" || char === "\r") {
|
|
725
|
+
return { end: index, plain: false };
|
|
726
|
+
} else {
|
|
727
|
+
index += 1;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return { end: source.length, plain: false };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Past template text starting at `from`: to just after the closing backtick,
|
|
735
|
+
* or just after a `${`, where code resumes.
|
|
736
|
+
*/
|
|
737
|
+
function afterTemplateText(source: string, from: number): number {
|
|
738
|
+
let index = from;
|
|
739
|
+
while (index < source.length) {
|
|
740
|
+
const char = source[index];
|
|
741
|
+
if (char === "\\") {
|
|
742
|
+
index += 2;
|
|
743
|
+
} else if (char === "`") {
|
|
744
|
+
return index + 1;
|
|
745
|
+
} else if (char === "$" && source[index + 1] === "{") {
|
|
746
|
+
return index + 2;
|
|
747
|
+
} else {
|
|
748
|
+
index += 1;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return source.length;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Past the regular expression literal opening at `from`, flags included. */
|
|
755
|
+
function afterRegularExpression(source: string, from: number): number {
|
|
756
|
+
let index = from + 1;
|
|
757
|
+
let inClass = false;
|
|
758
|
+
while (index < source.length) {
|
|
759
|
+
const char = source[index] as string;
|
|
760
|
+
if (LINE_TERMINATOR.test(char)) {
|
|
761
|
+
return index;
|
|
762
|
+
}
|
|
763
|
+
if (char === "\\") {
|
|
764
|
+
// Not even an escaped line break continues a regular expression.
|
|
765
|
+
if (LINE_TERMINATOR.test(source[index + 1] ?? "")) {
|
|
766
|
+
return index + 1;
|
|
767
|
+
}
|
|
768
|
+
index += 2;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (char === "[") {
|
|
772
|
+
inClass = true;
|
|
773
|
+
} else if (char === "]") {
|
|
774
|
+
inClass = false;
|
|
775
|
+
} else if (char === "/" && !inClass) {
|
|
776
|
+
index += 1;
|
|
777
|
+
while (index < source.length && /\w/.test(source[index] as string)) {
|
|
778
|
+
index += 1;
|
|
779
|
+
}
|
|
780
|
+
return index;
|
|
781
|
+
}
|
|
782
|
+
index += 1;
|
|
783
|
+
}
|
|
784
|
+
return source.length;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Whether a specifier names one of Node's built-in modules.
|
|
789
|
+
*
|
|
790
|
+
* A built-in resolves from any directory, the data volume included, and a
|
|
791
|
+
* plugin already runs in the host's process, so requiring one is outside what
|
|
792
|
+
* the host-package rule exists for.
|
|
793
|
+
*/
|
|
794
|
+
function isNodeBuiltinModule(specifier: string): boolean {
|
|
795
|
+
return specifier.startsWith("node:") || nodeBuiltinModules().has(specifier);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
let builtinModules: ReadonlySet<string> | undefined;
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* `module.builtinModules`, read through `process.getBuiltinModule`.
|
|
802
|
+
*
|
|
803
|
+
* Not imported from `node:module`: this package is also bundled into the
|
|
804
|
+
* browser for its constants, where that module does not exist. Outside Node
|
|
805
|
+
* the set is empty, and only a `node:` specifier is recognised.
|
|
806
|
+
*/
|
|
807
|
+
function nodeBuiltinModules(): ReadonlySet<string> {
|
|
808
|
+
if (builtinModules === undefined) {
|
|
809
|
+
const host = (
|
|
810
|
+
globalThis as {
|
|
811
|
+
process?: { getBuiltinModule?: (id: string) => unknown };
|
|
812
|
+
}
|
|
813
|
+
).process;
|
|
814
|
+
const moduleApi = host?.getBuiltinModule?.("node:module") as
|
|
815
|
+
{ builtinModules?: readonly string[] } | undefined;
|
|
816
|
+
builtinModules = new Set(moduleApi?.builtinModules ?? []);
|
|
817
|
+
}
|
|
818
|
+
return builtinModules;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function localeProblems(contents: PluginPackageContents): string[] {
|
|
822
|
+
const problems: string[] = [];
|
|
823
|
+
const files = new Set(contents.files.map(normalizedPath));
|
|
824
|
+
|
|
825
|
+
const keys: Partial<Record<"sv" | "en", ReadonlySet<string>>> = {};
|
|
826
|
+
for (const language of ["sv", "en"] as const) {
|
|
827
|
+
const path = `locales/${language}.json`;
|
|
828
|
+
const parsed = contents.locales[language];
|
|
829
|
+
if (!files.has(path) || parsed === undefined) {
|
|
830
|
+
problems.push(`The package has no ${path}.`);
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
const record = asRecord(parsed);
|
|
834
|
+
if (record === null) {
|
|
835
|
+
problems.push(`${path} is not a JSON object of translation keys.`);
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
keys[language] = new Set(leafKeys(record));
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const { sv, en } = keys;
|
|
842
|
+
if (sv !== undefined && en !== undefined) {
|
|
843
|
+
const missingFromSv = [...en].filter((key) => !sv.has(key));
|
|
844
|
+
const missingFromEn = [...sv].filter((key) => !en.has(key));
|
|
845
|
+
if (missingFromSv.length > 0) {
|
|
846
|
+
problems.push(
|
|
847
|
+
`locales/sv.json lacks ${missingFromSv.join(", ")}, which locales/en.json has. The two carry identical keys.`,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
if (missingFromEn.length > 0) {
|
|
851
|
+
problems.push(
|
|
852
|
+
`locales/en.json lacks ${missingFromEn.join(", ")}, which locales/sv.json has. The two carry identical keys.`,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return problems;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Dotted paths to every value that is not itself a nested object. */
|
|
861
|
+
function leafKeys(record: Record<string, unknown>, prefix = ""): string[] {
|
|
862
|
+
return Object.entries(record).flatMap(([key, value]) => {
|
|
863
|
+
const path = prefix === "" ? key : `${prefix}.${key}`;
|
|
864
|
+
const nested = asRecord(value);
|
|
865
|
+
return nested === null ? [path] : leafKeys(nested, path);
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** A package path without `./` or empty segments, as a file list spells it. */
|
|
870
|
+
function normalizedPath(path: string): string {
|
|
871
|
+
return path
|
|
872
|
+
.split("/")
|
|
873
|
+
.filter((segment) => segment !== "" && segment !== ".")
|
|
874
|
+
.join("/");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function withoutRuntimeDependencies(
|
|
878
|
+
packageJson: Record<string, unknown> | null,
|
|
879
|
+
): unknown {
|
|
880
|
+
if (packageJson === null) {
|
|
881
|
+
return packageJson;
|
|
882
|
+
}
|
|
883
|
+
const rest = { ...packageJson };
|
|
884
|
+
for (const field of RUNTIME_DEPENDENCY_FIELDS) {
|
|
885
|
+
delete rest[field];
|
|
886
|
+
}
|
|
887
|
+
return rest;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
891
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
892
|
+
? (value as Record<string, unknown>)
|
|
893
|
+
: null;
|
|
894
|
+
}
|