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