@effected/workspaces 0.25.0 → 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.
@@ -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 };