@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.
@@ -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 };