@effected/workspaces 0.25.0 → 0.27.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/LayerPolicy.js +133 -0
- package/PackedInstall.js +761 -0
- package/README.md +244 -0
- package/SourceBoundary.js +624 -0
- package/WorkspaceLayering.js +199 -0
- package/internal/dependencyFields.js +17 -0
- package/internal/packedInstallPlan.js +239 -0
- package/internal/sourceText.js +359 -0
- package/package.json +6 -1
- package/testing.d.ts +1837 -0
- package/testing.js +10 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { isIdentifierChar, lex, locate, references, specifierLiterals } from "./internal/sourceText.js";
|
|
2
|
+
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
|
+
import { GlobSet } from "@effected/glob";
|
|
4
|
+
|
|
5
|
+
//#region src/SourceBoundary.ts
|
|
6
|
+
/**
|
|
7
|
+
* One place a source file breaks a {@link BoundaryRule}.
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
var Offence = class extends Schema.Class("Offence")({
|
|
12
|
+
/** The file, relative to the scanned root, with `/` separators. */
|
|
13
|
+
file: Schema.String,
|
|
14
|
+
/** The 1-based line. */
|
|
15
|
+
line: Schema.Number,
|
|
16
|
+
/** The 1-based column, in UTF-16 code units. */
|
|
17
|
+
column: Schema.Number,
|
|
18
|
+
/** The rule broken. */
|
|
19
|
+
rule: Schema.Literals([
|
|
20
|
+
"process",
|
|
21
|
+
"node:process",
|
|
22
|
+
"stdout-write",
|
|
23
|
+
"console",
|
|
24
|
+
"console-stdout",
|
|
25
|
+
"forbidImports",
|
|
26
|
+
"forbidTokens"
|
|
27
|
+
]),
|
|
28
|
+
/** What matched: the identifier, the call, the import specifier, or the token. */
|
|
29
|
+
detail: Schema.String
|
|
30
|
+
}) {
|
|
31
|
+
/** `file:line:column rule detail`, the form an assertion message reads best in. */
|
|
32
|
+
get label() {
|
|
33
|
+
return `${this.file}:${this.line}:${this.column} ${this.rule} ${this.detail}`;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* What a scan read and found.
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
var SourceScan = class extends Schema.Class("SourceScan")({
|
|
42
|
+
/** Every source file visited, relative to the root with `/` separators, sorted. Assert it is non-empty. */
|
|
43
|
+
files: Schema.Array(Schema.String),
|
|
44
|
+
/** The visited files an `allow` glob exempted from every rule. */
|
|
45
|
+
allowed: Schema.Array(Schema.String),
|
|
46
|
+
/** Every offence, sorted by file, then line, then column. */
|
|
47
|
+
offences: Schema.Array(Offence),
|
|
48
|
+
/**
|
|
49
|
+
* Every offence an `allowRules` glob waived, sorted like `offences`. Assert
|
|
50
|
+
* it is exactly what you meant to waive: a waiver that no longer waives
|
|
51
|
+
* anything, or waives more than intended, shows up here.
|
|
52
|
+
*/
|
|
53
|
+
waived: Schema.Array(Offence)
|
|
54
|
+
}) {
|
|
55
|
+
/** One `file:line:column rule detail` label per offence: `[]` means clean. */
|
|
56
|
+
get violations() {
|
|
57
|
+
return this.offences.map((offence) => offence.label);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const DEFAULT_EXTENSIONS = [
|
|
61
|
+
".ts",
|
|
62
|
+
".mts",
|
|
63
|
+
".cts",
|
|
64
|
+
".js",
|
|
65
|
+
".mjs",
|
|
66
|
+
".cjs"
|
|
67
|
+
];
|
|
68
|
+
const DECLARATION = /\.d\.[cm]?ts$/;
|
|
69
|
+
const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
70
|
+
const byPosition = (a, b) => byCodeUnit(a.file, b.file) || a.line - b.line || a.column - b.column;
|
|
71
|
+
const EXEMPT = ["process.env.__PACKAGE_VERSION__"];
|
|
72
|
+
/**
|
|
73
|
+
* The console methods Node writes to stderr. nodejs.org/api/console.html has
|
|
74
|
+
* `error` and `trace` print to stderr and `warn` alias `error`; it names no
|
|
75
|
+
* stream for `assert`, and a probe over a `Console` built on two capturing
|
|
76
|
+
* streams sent a failing `assert` to stderr. Everything else that writes
|
|
77
|
+
* (`log`, `info`, `debug`, `dir`, `dirxml`, `table`, `count`, `group`,
|
|
78
|
+
* `time`) writes to stdout.
|
|
79
|
+
*/
|
|
80
|
+
const STDERR_METHODS = /* @__PURE__ */ new Set([
|
|
81
|
+
"error",
|
|
82
|
+
"warn",
|
|
83
|
+
"trace",
|
|
84
|
+
"assert"
|
|
85
|
+
]);
|
|
86
|
+
const STDOUT_WRITE = /stdout\s*(?:\?\.|\.)\s*(write|end)/gu;
|
|
87
|
+
/** An `end(` call that passes a final chunk, which `Writable.end` writes before closing. */
|
|
88
|
+
const END_WITH_CHUNK = /^\s*\(\s*[^\s)]/u;
|
|
89
|
+
/** Whether the reference at `at` is itself a member access (`globalThis.process`), which no ignore token exempts. */
|
|
90
|
+
const isMemberAccess = (code, at) => {
|
|
91
|
+
let j = at - 1;
|
|
92
|
+
while (j >= 0 && /\s/.test(code[j] ?? "")) j--;
|
|
93
|
+
return code[j] === ".";
|
|
94
|
+
};
|
|
95
|
+
const processReads = (code, ignoreTokens) => references(code, "process").filter((at) => isMemberAccess(code, at) || !ignoreTokens.some((token) => code.startsWith(token, at) && !isIdentifierChar(code[at + token.length])));
|
|
96
|
+
const stdoutWrites = (code) => [...code.matchAll(STDOUT_WRITE)].map((match) => ({
|
|
97
|
+
at: match.index ?? 0,
|
|
98
|
+
end: (match.index ?? 0) + match[0].length,
|
|
99
|
+
method: match[1] ?? "write"
|
|
100
|
+
})).filter(({ at, end }) => !isIdentifierChar(code[at - 1]) && code[at - 1] !== "#" && !isIdentifierChar(code[end])).filter(({ end, method }) => method === "write" || END_WITH_CHUNK.test(code.slice(end))).map(({ at, method }) => ({
|
|
101
|
+
at,
|
|
102
|
+
method
|
|
103
|
+
}));
|
|
104
|
+
/** The member name directly after the reference at `at` (`console.log` → `"log"`, `console?.log` too), or `undefined` when it is not a named member access. */
|
|
105
|
+
const memberAfter = (code, at, name) => {
|
|
106
|
+
let j = at + name.length;
|
|
107
|
+
while (j < code.length && /\s/.test(code[j] ?? "")) j++;
|
|
108
|
+
if (code[j] === "?" && code[j + 1] === ".") j++;
|
|
109
|
+
if (code[j] !== ".") return void 0;
|
|
110
|
+
j++;
|
|
111
|
+
while (j < code.length && /\s/.test(code[j] ?? "")) j++;
|
|
112
|
+
let end = j;
|
|
113
|
+
while (isIdentifierChar(code[end])) end++;
|
|
114
|
+
return end > j ? code.slice(j, end) : void 0;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Every offset where `token` appears in `code` as whole text: a token that
|
|
118
|
+
* starts with an identifier character must not continue one (nor follow `#`),
|
|
119
|
+
* and one that ends with an identifier character must not run into another.
|
|
120
|
+
*/
|
|
121
|
+
const tokenOccurrences = (code, token) => {
|
|
122
|
+
if (token.length === 0) return [];
|
|
123
|
+
const guardStart = isIdentifierChar(token[0]);
|
|
124
|
+
const guardEnd = isIdentifierChar(token[token.length - 1]);
|
|
125
|
+
const found = [];
|
|
126
|
+
for (let at = code.indexOf(token); at !== -1; at = code.indexOf(token, at + 1)) {
|
|
127
|
+
if (guardStart && (isIdentifierChar(code[at - 1]) || code[at - 1] === "#")) continue;
|
|
128
|
+
if (guardEnd && isIdentifierChar(code[at + token.length])) continue;
|
|
129
|
+
found.push(at);
|
|
130
|
+
}
|
|
131
|
+
return found;
|
|
132
|
+
};
|
|
133
|
+
const forbids = (entry, specifier) => entry.endsWith("*") ? specifier.startsWith(entry.slice(0, -1)) : specifier === entry || specifier.startsWith(`${entry}/`);
|
|
134
|
+
const isNodeModule = (specifier, module) => [module, `node:${module}`].some((name) => specifier === name || specifier.startsWith(`${name}/`));
|
|
135
|
+
const FIXTURES = [
|
|
136
|
+
{
|
|
137
|
+
name: "process: destructuring",
|
|
138
|
+
source: "const { env } = process;",
|
|
139
|
+
rule: "process",
|
|
140
|
+
flagged: true
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "process: bracket access",
|
|
144
|
+
source: "const env = process[\"env\"];",
|
|
145
|
+
rule: "process",
|
|
146
|
+
flagged: true
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "process: a member of globalThis",
|
|
150
|
+
source: "const cwd = globalThis.process?.cwd();",
|
|
151
|
+
rule: "process",
|
|
152
|
+
flagged: true
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "process: a template substitution",
|
|
156
|
+
source: "const where = `cwd: ${process.cwd()}`;",
|
|
157
|
+
rule: "process",
|
|
158
|
+
flagged: true
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "process: after a string holding a comment opener",
|
|
162
|
+
source: "const open = \"/*\"; const argv = process.argv; const close = \"*/\";",
|
|
163
|
+
rule: "process",
|
|
164
|
+
flagged: true
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "process: after a regex holding a quote",
|
|
168
|
+
source: "const re = /\"/; const argv = process.argv; const q = \"x\";",
|
|
169
|
+
rule: "process",
|
|
170
|
+
flagged: true
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "process: after a division",
|
|
174
|
+
source: "const half = total / 2; const argv = process.argv; const r = 1 / 4;",
|
|
175
|
+
rule: "process",
|
|
176
|
+
flagged: true
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "process: a comment",
|
|
180
|
+
source: "// process.exit(1)\n/* process.cwd() */",
|
|
181
|
+
rule: "process",
|
|
182
|
+
flagged: false
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "process: a string",
|
|
186
|
+
source: "const label = \"process.env\";",
|
|
187
|
+
rule: "process",
|
|
188
|
+
flagged: false
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "process: template text",
|
|
192
|
+
source: "const help = `run process.exit to quit`;",
|
|
193
|
+
rule: "process",
|
|
194
|
+
flagged: false
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: "process: a regex literal",
|
|
198
|
+
source: "const re = /process\\.env/g;",
|
|
199
|
+
rule: "process",
|
|
200
|
+
flagged: false
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "process: a member of another object",
|
|
204
|
+
source: "const pid = child.process;",
|
|
205
|
+
rule: "process",
|
|
206
|
+
flagged: false
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: "process: the build-time version constant",
|
|
210
|
+
source: "const version = process.env.__PACKAGE_VERSION__;",
|
|
211
|
+
rule: "process",
|
|
212
|
+
flagged: false
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: "process: the version constant reached through globalThis",
|
|
216
|
+
source: "const version = globalThis.process.env.__PACKAGE_VERSION__;",
|
|
217
|
+
rule: "process",
|
|
218
|
+
flagged: true
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: "process: after a non-null assertion divided",
|
|
222
|
+
source: "const y = x! / 2; const a = process.argv; const b = 1 / 4;",
|
|
223
|
+
rule: "process",
|
|
224
|
+
flagged: true
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "process: after a postfix increment divided",
|
|
228
|
+
source: "const y = i++ / 2; const a = process.argv; const b = 1 / 4;",
|
|
229
|
+
rule: "process",
|
|
230
|
+
flagged: true
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "process: after a call divided",
|
|
234
|
+
source: "const y = f(x) / 2; const a = process.argv; const b = 1 / 4;",
|
|
235
|
+
rule: "process",
|
|
236
|
+
flagged: true
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "process: after a regex that follows an if condition",
|
|
240
|
+
source: "if (x) /\\/*/.test(s);\nconst a = process.argv;\n/* c */",
|
|
241
|
+
rule: "process",
|
|
242
|
+
flagged: true
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "process: a ternary branch",
|
|
246
|
+
source: "const p = ok ? process : fallback;",
|
|
247
|
+
rule: "process",
|
|
248
|
+
flagged: true
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "process: an object-literal key",
|
|
252
|
+
source: "const o = { process: 1 };",
|
|
253
|
+
rule: "process",
|
|
254
|
+
flagged: false
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "process: a type member",
|
|
258
|
+
source: "interface I { process?: string }",
|
|
259
|
+
rule: "process",
|
|
260
|
+
flagged: false
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: "node:process: a static import",
|
|
264
|
+
source: "import { env } from \"node:process\";",
|
|
265
|
+
rule: "node:process",
|
|
266
|
+
flagged: true
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: "node:process: a dynamic import",
|
|
270
|
+
source: "const p = await import(\"process\");",
|
|
271
|
+
rule: "node:process",
|
|
272
|
+
flagged: true
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: "node:process: a string that is not a specifier",
|
|
276
|
+
source: "const s = \"node:process\";",
|
|
277
|
+
rule: "node:process",
|
|
278
|
+
flagged: false
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: "node:process: an unrelated specifier",
|
|
282
|
+
source: "import { ChildProcess } from \"effect/unstable/process\";",
|
|
283
|
+
rule: "node:process",
|
|
284
|
+
flagged: false
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: "stdout-write: a direct write",
|
|
288
|
+
source: "process.stdout.write(\"x\");",
|
|
289
|
+
rule: "stdout-write",
|
|
290
|
+
flagged: true
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "stdout-write: an end with a chunk",
|
|
294
|
+
source: "process.stdout.end(\"x\");",
|
|
295
|
+
rule: "stdout-write",
|
|
296
|
+
flagged: true
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "stdout-write: an end without a chunk",
|
|
300
|
+
source: "process.stdout.end();",
|
|
301
|
+
rule: "stdout-write",
|
|
302
|
+
flagged: false
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: "stdout-write: a string",
|
|
306
|
+
source: "const s = \"stdout.write\";",
|
|
307
|
+
rule: "stdout-write",
|
|
308
|
+
flagged: false
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: "console: console.log",
|
|
312
|
+
source: "console.log(\"x\");",
|
|
313
|
+
rule: "console",
|
|
314
|
+
flagged: true
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: "console: console.error",
|
|
318
|
+
source: "console.error(e);",
|
|
319
|
+
rule: "console",
|
|
320
|
+
flagged: true
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: "console: the Console service",
|
|
324
|
+
source: "const current = yield* Console.Console;",
|
|
325
|
+
rule: "console",
|
|
326
|
+
flagged: false
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: "console-stdout: console.log",
|
|
330
|
+
source: "console.log(\"x\");",
|
|
331
|
+
rule: "console-stdout",
|
|
332
|
+
flagged: true
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: "console-stdout: a bare alias",
|
|
336
|
+
source: "const c = console;",
|
|
337
|
+
rule: "console-stdout",
|
|
338
|
+
flagged: true
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: "console-stdout: console.error",
|
|
342
|
+
source: "console.error(e);",
|
|
343
|
+
rule: "console-stdout",
|
|
344
|
+
flagged: false
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: "console-stdout: a stderr method through globalThis",
|
|
348
|
+
source: "globalThis.console.warn(\"x\");",
|
|
349
|
+
rule: "console-stdout",
|
|
350
|
+
flagged: false
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: "console-stdout: the Console service",
|
|
354
|
+
source: "const current = yield* Console.Console;",
|
|
355
|
+
rule: "console-stdout",
|
|
356
|
+
flagged: false
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: "forbidImports: a platform package",
|
|
360
|
+
source: "import { NodeServices } from \"@effect/platform-node\";",
|
|
361
|
+
rule: { forbidImports: ["@effect/platform*"] },
|
|
362
|
+
flagged: true
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: "forbidImports: a commented-out import",
|
|
366
|
+
source: "// import { NodeServices } from \"@effect/platform-node\";",
|
|
367
|
+
rule: { forbidImports: ["@effect/platform*"] },
|
|
368
|
+
flagged: false
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: "forbidTokens: the build-time version constant",
|
|
372
|
+
source: "export const version = process.env.__PACKAGE_VERSION__;",
|
|
373
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
374
|
+
flagged: true
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: "forbidTokens: the token reached through globalThis",
|
|
378
|
+
source: "const version = globalThis.process.env.__PACKAGE_VERSION__;",
|
|
379
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
380
|
+
flagged: true
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: "forbidTokens: a string and a comment",
|
|
384
|
+
source: "const s = \"process.env.__PACKAGE_VERSION__\"; // process.env.__PACKAGE_VERSION__",
|
|
385
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
386
|
+
flagged: false
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "forbidTokens: a longer identifier",
|
|
390
|
+
source: "const v = process.env.__PACKAGE_VERSION__X;",
|
|
391
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
392
|
+
flagged: false
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
name: "forbidTokens: the tail of a longer identifier",
|
|
396
|
+
source: "const v = xprocess.env.__PACKAGE_VERSION__;",
|
|
397
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
398
|
+
flagged: false
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: "forbidTokens: after a variable named of, divided",
|
|
402
|
+
source: "const of = 8; const x = of / 2; const v = process.env.__PACKAGE_VERSION__; const y = 8 / 2;",
|
|
403
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
404
|
+
flagged: true
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
name: "forbidTokens: a private field",
|
|
408
|
+
source: "const v = this.#process.env.__PACKAGE_VERSION__;",
|
|
409
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
410
|
+
flagged: false
|
|
411
|
+
}
|
|
412
|
+
];
|
|
413
|
+
/**
|
|
414
|
+
* Source-text boundary checks: which files read `process`, import a forbidden
|
|
415
|
+
* module, or write to stdout or the console.
|
|
416
|
+
*
|
|
417
|
+
* @remarks
|
|
418
|
+
* Every check runs over one lexer pass that separates code from comments,
|
|
419
|
+
* strings, template text and regex bodies, so `process` in prose, in a string
|
|
420
|
+
* or in an embedded script's template text never counts, and a `/*` inside a
|
|
421
|
+
* string never hides the code behind it. A `/` after an operand divides (an
|
|
422
|
+
* identifier, `)`, `]`, a postfix `++`/`--` or a non-null `!`), while one after
|
|
423
|
+
* an operator, a keyword or the `)` of an `if`/`while`/`for`/`with` condition
|
|
424
|
+
* opens a regex. An object-literal key or type member named `process`
|
|
425
|
+
* (`{ process: 1 }`) is not a read.
|
|
426
|
+
*
|
|
427
|
+
* It is a lexer, not a type checker, and these misses are known:
|
|
428
|
+
*
|
|
429
|
+
* - there is no scope analysis, so ANY local binding named `process` or
|
|
430
|
+
* `console` is flagged like the global: a parameter
|
|
431
|
+
* (`(process: Handle) => process.kill()`), a variable, a label, or an
|
|
432
|
+
* unannotated class field (`process = 1`). An annotated class field
|
|
433
|
+
* (`process: T`) reads as a type member and is spared. Prefer renaming the
|
|
434
|
+
* binding; otherwise waive that one rule for the file with an
|
|
435
|
+
* `allowRules` glob, which leaves every other rule in force and reports
|
|
436
|
+
* what it waived. An `allow` glob exempts the file from every rule;
|
|
437
|
+
*
|
|
438
|
+
* - a computed access through a string key (`globalThis["process"]`) and a
|
|
439
|
+
* destructuring of a global (`const { process: p } = globalThis`) are not seen;
|
|
440
|
+
*
|
|
441
|
+
* - a regex literal directly after a block-closing `}` reads as a division, so
|
|
442
|
+
* a quote or `/*` inside it can hide the code after it;
|
|
443
|
+
*
|
|
444
|
+
* - JSX text reads as code;
|
|
445
|
+
*
|
|
446
|
+
* - a variable named `yield` or `await` in a sloppy-mode script reads as the
|
|
447
|
+
* keyword, so a `/` after it opens a regex (module and strict code reserve
|
|
448
|
+
* both words);
|
|
449
|
+
*
|
|
450
|
+
* - `forbidImports: ["node:*"]` does not catch a bare built-in such as
|
|
451
|
+
* `"fs"` (see {@link BoundaryRule}).
|
|
452
|
+
*
|
|
453
|
+
* Assert on {@link SourceBoundary.verifyFixtures} beside your own scan: it
|
|
454
|
+
* proves the scanner you are trusting still flags what it must and spares
|
|
455
|
+
* what it must.
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* ```ts
|
|
459
|
+
* import { SourceBoundary } from "@effected/workspaces/testing";
|
|
460
|
+
*
|
|
461
|
+
* const offences = SourceBoundary.check("src/a.ts", "const { env } = process;", ["process"]);
|
|
462
|
+
* const clean = SourceBoundary.verifyFixtures().length === 0;
|
|
463
|
+
* console.log(offences.map((offence) => offence.label), clean);
|
|
464
|
+
* // => [ 'src/a.ts:1:17 process process' ] true
|
|
465
|
+
* ```
|
|
466
|
+
*
|
|
467
|
+
* @public
|
|
468
|
+
*/
|
|
469
|
+
var SourceBoundary = class SourceBoundary {
|
|
470
|
+
constructor() {}
|
|
471
|
+
/** `text` with every comment blanked to spaces; strings, templates and regexes untouched, length and line breaks kept. */
|
|
472
|
+
static stripComments = (text) => lex(text).withoutComments;
|
|
473
|
+
/** Whether `text` reads the global `process` in code (see {@link BoundaryRule}). */
|
|
474
|
+
static referencesProcess = (text, options) => processReads(lex(text).code, [...EXEMPT, ...options?.ignoreTokens ?? []]).length > 0;
|
|
475
|
+
/** Every module specifier `text` imports: static, side-effect, re-export, type-only, `import(...)` with a literal, and `require(...)`. */
|
|
476
|
+
static importSpecifiers = (text) => specifierLiterals(lex(text)).map((literal) => literal.value);
|
|
477
|
+
/** Whether `text` imports the Node built-in `module`, in either its `node:` or bare form. */
|
|
478
|
+
static importsNode = (text, module) => SourceBoundary.importSpecifiers(text).some((specifier) => isNodeModule(specifier, module));
|
|
479
|
+
/**
|
|
480
|
+
* Every place `text` breaks one of `rules`, in source order, attributed to `file`.
|
|
481
|
+
*
|
|
482
|
+
* @remarks
|
|
483
|
+
* A `{ forbidTokens }` entry matches its exact text in code only: comments,
|
|
484
|
+
* strings, template text and regex bodies are blanked first, so a token that
|
|
485
|
+
* itself contains a string literal never matches, and whitespace inside a
|
|
486
|
+
* token must match byte for byte. A token that starts with an identifier
|
|
487
|
+
* character does not match inside a longer identifier or after `#`, and one
|
|
488
|
+
* that ends with one does not match when an identifier character follows.
|
|
489
|
+
* A member access is still a match: `globalThis.process.env.X` contains the
|
|
490
|
+
* token `process.env.X`. A token listed twice is reported once. A token with
|
|
491
|
+
* no identifier character at either end (`"=>"`, `"?."`) has no edge guard,
|
|
492
|
+
* so it matches inside longer punctuation, and consecutive matches of it can
|
|
493
|
+
* overlap.
|
|
494
|
+
*/
|
|
495
|
+
static check = (file, text, rules, options) => {
|
|
496
|
+
const lexed = lex(text);
|
|
497
|
+
const at = locate(text);
|
|
498
|
+
const specifiers = specifierLiterals(lexed);
|
|
499
|
+
const found = [];
|
|
500
|
+
for (const rule of rules) if (rule === "process") for (const offset of processReads(lexed.code, [...EXEMPT, ...options?.ignoreTokens ?? []])) found.push({
|
|
501
|
+
offset,
|
|
502
|
+
rule,
|
|
503
|
+
detail: "process"
|
|
504
|
+
});
|
|
505
|
+
else if (rule === "node:process") {
|
|
506
|
+
for (const literal of specifiers) if (isNodeModule(literal.value, "process")) found.push({
|
|
507
|
+
offset: literal.start,
|
|
508
|
+
rule,
|
|
509
|
+
detail: literal.value
|
|
510
|
+
});
|
|
511
|
+
} else if (rule === "stdout-write") for (const { at, method } of stdoutWrites(lexed.code)) found.push({
|
|
512
|
+
offset: at,
|
|
513
|
+
rule,
|
|
514
|
+
detail: `stdout.${method}`
|
|
515
|
+
});
|
|
516
|
+
else if (rule === "console") for (const offset of references(lexed.code, "console")) found.push({
|
|
517
|
+
offset,
|
|
518
|
+
rule,
|
|
519
|
+
detail: "console"
|
|
520
|
+
});
|
|
521
|
+
else if (rule === "console-stdout") for (const offset of references(lexed.code, "console")) {
|
|
522
|
+
const member = memberAfter(lexed.code, offset, "console");
|
|
523
|
+
if (member !== void 0 && STDERR_METHODS.has(member)) continue;
|
|
524
|
+
found.push({
|
|
525
|
+
offset,
|
|
526
|
+
rule,
|
|
527
|
+
detail: member === void 0 ? "console" : `console.${member}`
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
else if ("forbidTokens" in rule) for (const token of new Set(rule.forbidTokens)) for (const offset of tokenOccurrences(lexed.code, token)) found.push({
|
|
531
|
+
offset,
|
|
532
|
+
rule: "forbidTokens",
|
|
533
|
+
detail: token
|
|
534
|
+
});
|
|
535
|
+
else for (const literal of specifiers) if (rule.forbidImports.some((entry) => forbids(entry, literal.value))) found.push({
|
|
536
|
+
offset: literal.start,
|
|
537
|
+
rule: "forbidImports",
|
|
538
|
+
detail: literal.value
|
|
539
|
+
});
|
|
540
|
+
return found.sort((a, b) => a.offset - b.offset).map(({ offset, rule, detail }) => Offence.make({
|
|
541
|
+
file,
|
|
542
|
+
...at(offset),
|
|
543
|
+
rule,
|
|
544
|
+
detail
|
|
545
|
+
}));
|
|
546
|
+
};
|
|
547
|
+
/**
|
|
548
|
+
* Check every source file under `root` against `rules`.
|
|
549
|
+
*
|
|
550
|
+
* @remarks
|
|
551
|
+
* Walks with an explicit stack, visiting each real directory once (via
|
|
552
|
+
* `realPath`), so a symlink loop terminates and a linked directory is not
|
|
553
|
+
* scanned twice. `node_modules` is never entered. Paths come back relative
|
|
554
|
+
* and `/`-separated whatever the platform's separator, and that is also
|
|
555
|
+
* what `allow` globs match against. A missing root fails; it never scans
|
|
556
|
+
* nothing. A dangling symlink under the root is skipped, having nothing to
|
|
557
|
+
* scan; any other entry that cannot be read fails the scan.
|
|
558
|
+
*
|
|
559
|
+
* @example
|
|
560
|
+
* ```ts
|
|
561
|
+
* import { NodeServices } from "@effect/platform-node";
|
|
562
|
+
* import { SourceBoundary } from "@effected/workspaces/testing";
|
|
563
|
+
* import { Effect } from "effect";
|
|
564
|
+
*
|
|
565
|
+
* const scan = SourceBoundary.scan({
|
|
566
|
+
* root: "/repo/packages/engine/src",
|
|
567
|
+
* rules: ["process", "node:process", { forbidImports: ["node:*", "@effect/platform*"] }],
|
|
568
|
+
* }).pipe(Effect.provide(NodeServices.layer));
|
|
569
|
+
* ```
|
|
570
|
+
*/
|
|
571
|
+
static scan = Effect.fn("SourceBoundary.scan")(function* (options) {
|
|
572
|
+
const fs = yield* FileSystem.FileSystem;
|
|
573
|
+
const path = yield* Path.Path;
|
|
574
|
+
const allow = yield* GlobSet.compile(options.allow ?? []);
|
|
575
|
+
const allowRules = /* @__PURE__ */ new Map();
|
|
576
|
+
for (const [rule, patterns] of Object.entries(options.allowRules ?? {})) if (patterns !== void 0) allowRules.set(rule, yield* GlobSet.compile(patterns));
|
|
577
|
+
const extensions = options.extensions ?? DEFAULT_EXTENSIONS;
|
|
578
|
+
const posix = (relative) => relative.split(path.sep).join("/");
|
|
579
|
+
const files = [];
|
|
580
|
+
const allowed = [];
|
|
581
|
+
const offences = [];
|
|
582
|
+
const waived = [];
|
|
583
|
+
const visited = /* @__PURE__ */ new Set();
|
|
584
|
+
const pending = [options.root];
|
|
585
|
+
while (pending.length > 0) {
|
|
586
|
+
const directory = pending.pop();
|
|
587
|
+
if (directory === void 0) break;
|
|
588
|
+
const real = yield* fs.realPath(directory);
|
|
589
|
+
if (visited.has(real)) continue;
|
|
590
|
+
visited.add(real);
|
|
591
|
+
for (const name of yield* fs.readDirectory(directory)) {
|
|
592
|
+
const full = path.join(directory, name);
|
|
593
|
+
const found = yield* fs.stat(full).pipe(Effect.map(Option.some), Effect.catch((error) => error.reason._tag === "NotFound" ? fs.readLink(full).pipe(Effect.as(Option.none()), Effect.mapError(() => error)) : Effect.fail(error)));
|
|
594
|
+
if (Option.isNone(found)) continue;
|
|
595
|
+
const info = found.value;
|
|
596
|
+
if (info.type === "Directory") {
|
|
597
|
+
if (name !== "node_modules") pending.push(full);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (info.type !== "File" || DECLARATION.test(name) || !extensions.some((extension) => name.endsWith(extension))) continue;
|
|
601
|
+
const file = posix(path.relative(options.root, full));
|
|
602
|
+
files.push(file);
|
|
603
|
+
if (allow.matches(file)) {
|
|
604
|
+
allowed.push(file);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
for (const offence of SourceBoundary.check(file, yield* fs.readFileString(full), options.rules, options)) (allowRules.get(offence.rule)?.matches(file) === true ? waived : offences).push(offence);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return SourceScan.make({
|
|
611
|
+
files: files.sort(byCodeUnit),
|
|
612
|
+
allowed: allowed.sort(byCodeUnit),
|
|
613
|
+
offences: offences.sort(byPosition),
|
|
614
|
+
waived: waived.sort(byPosition)
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
/** The shipped positive- and negative-control snippets, at least one of each per rule kind. */
|
|
618
|
+
static fixtures = FIXTURES;
|
|
619
|
+
/** The names of shipped fixtures the scanner now gets wrong; `[]` means it still flags and spares what it must. */
|
|
620
|
+
static verifyFixtures = () => FIXTURES.filter((fixture) => SourceBoundary.check("fixture.ts", fixture.source, [fixture.rule]).length > 0 !== fixture.flagged).map((fixture) => `${fixture.flagged ? "missed" : "false positive"}: ${fixture.name}`);
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
//#endregion
|
|
624
|
+
export { Offence, SourceBoundary, SourceScan };
|