@effected/workspaces 0.32.1 → 0.34.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/ImportGraph.js ADDED
@@ -0,0 +1,525 @@
1
+ import { WorkspaceDiscovery } from "./WorkspaceDiscovery.js";
2
+ import { isIdentifierChar, lex, locate, specifierLiterals, wordEndingAt } from "./internal/sourceText.js";
3
+ import { isTypePositionImport } from "./internal/importPosition.js";
4
+ import { DEFAULT_EXTENSIONS, walkSources } from "./internal/sourceWalk.js";
5
+ import { SourceBoundary } from "./SourceBoundary.js";
6
+ import { Effect, FileSystem, Option, Path, Schema } from "effect";
7
+ import { GlobSet } from "@effected/glob";
8
+
9
+ //#region src/ImportGraph.ts
10
+ /**
11
+ * The entries list named no file to start from, so a walk would prove nothing.
12
+ *
13
+ * @public
14
+ */
15
+ var NoEntriesError = class extends Schema.TaggedError()("NoEntriesError", {
16
+ /** The root the walk would have reported against. */
17
+ root: Schema.String }) {
18
+ /** Renders the root into a one-line message. */
19
+ get message() {
20
+ return `No entries to walk under "${this.root}": a walk of nothing proves nothing`;
21
+ }
22
+ };
23
+ /**
24
+ * An entry named a file no resolution candidate could find. The walk fails
25
+ * rather than starting from nothing: a typo'd entry must never read as clean.
26
+ *
27
+ * @public
28
+ */
29
+ var EntryNotFoundError = class extends Schema.TaggedError()("EntryNotFoundError", {
30
+ /** The entry, verbatim. */
31
+ entry: Schema.String,
32
+ /** The root it was resolved against. */
33
+ root: Schema.String
34
+ }) {
35
+ /** Renders the entry and root into a one-line message. */
36
+ get message() {
37
+ return `Entry "${this.entry}" resolves to no file under "${this.root}"`;
38
+ }
39
+ };
40
+ /**
41
+ * One relative import no file candidate resolved: a link of the graph the
42
+ * walk could not follow, so whatever it would have reached is unproven.
43
+ *
44
+ * @public
45
+ */
46
+ var UnresolvedImport = class extends Schema.Class("UnresolvedImport")({
47
+ /** The importing file, relative to the root, with `/` separators. */
48
+ file: Schema.String,
49
+ /** The specifier, verbatim. */
50
+ specifier: Schema.String
51
+ }) {
52
+ /** `file specifier`, the form an assertion message reads best in. */
53
+ get label() {
54
+ return `${this.file} ${this.specifier}`;
55
+ }
56
+ };
57
+ /**
58
+ * One forbidden import a walk from the entries reached.
59
+ *
60
+ * @public
61
+ */
62
+ var ReachOffence = class extends Schema.Class("ReachOffence")({
63
+ /** The importing file, relative to the root, with `/` separators. */
64
+ file: Schema.String,
65
+ /** The 1-based line. */
66
+ line: Schema.Number,
67
+ /** The 1-based column, in UTF-16 code units. */
68
+ column: Schema.Number,
69
+ /** The forbidden specifier, verbatim. */
70
+ specifier: Schema.String,
71
+ /**
72
+ * The files the walk took from an entry to `file`, entry first and `file`
73
+ * last, each relative to the root with `/` separators. Breadth-first, so
74
+ * this is a shortest chain; it is the chain the walk first reached `file`
75
+ * by, and a file is visited once whatever else reaches it.
76
+ */
77
+ chain: Schema.Array(Schema.String)
78
+ }) {
79
+ /** `file:line:column specifier via chain`, the form an assertion message reads best in. */
80
+ get label() {
81
+ return `${this.file}:${this.line}:${this.column} ${this.specifier} via ${this.chain.join(" -> ")}`;
82
+ }
83
+ };
84
+ /**
85
+ * What a reachability walk read and found.
86
+ *
87
+ * @public
88
+ */
89
+ var ReachabilityScan = class extends Schema.Class("ReachabilityScan")({
90
+ /**
91
+ * Every file the walk reached, entries included, relative to the root with
92
+ * `/` separators, sorted. Assert it is non-empty: a walk that reached
93
+ * nothing proves nothing.
94
+ */
95
+ files: Schema.Array(Schema.String),
96
+ /**
97
+ * Every relative specifier no file candidate resolved, one per
98
+ * file-and-specifier pair, sorted by file then specifier. Assert it is
99
+ * empty, or exactly the generated files you expect: an unresolved link is
100
+ * a part of the graph the walk could not prove anything about.
101
+ */
102
+ unresolved: Schema.Array(UnresolvedImport),
103
+ /** Every forbidden import the walk reached, sorted by file, then line, then column. */
104
+ offences: Schema.Array(ReachOffence)
105
+ }) {
106
+ /** One `file:line:column specifier via chain` label per offence: `[]` means the entries reach nothing forbidden. */
107
+ get violations() {
108
+ return this.offences.map((offence) => offence.label);
109
+ }
110
+ };
111
+ /**
112
+ * One runtime import the owning package's manifest does not declare.
113
+ *
114
+ * @public
115
+ */
116
+ var UndeclaredImport = class extends Schema.Class("UndeclaredImport")({
117
+ /** The name of the package that owns the importing file. */
118
+ package: Schema.String,
119
+ /** The importing file, relative to its package, with `/` separators. */
120
+ file: Schema.String,
121
+ /** The 1-based line. */
122
+ line: Schema.Number,
123
+ /** The 1-based column, in UTF-16 code units. */
124
+ column: Schema.Number,
125
+ /** The specifier, verbatim. */
126
+ specifier: Schema.String,
127
+ /** The package name the specifier names: `effect` for `effect/Schema`. */
128
+ dependency: Schema.String
129
+ }) {
130
+ /** `package file:line:column undeclared dependency`, the form an assertion message reads best in. */
131
+ get label() {
132
+ return `${this.package} ${this.file}:${this.line}:${this.column} undeclared ${this.dependency}`;
133
+ }
134
+ };
135
+ /**
136
+ * What an undeclared-import check read and found.
137
+ *
138
+ * @public
139
+ */
140
+ var UndeclaredScan = class extends Schema.Class("UndeclaredScan")({
141
+ /** Every package whose manifest the check read, sorted by name. Assert it is non-empty. */
142
+ packages: Schema.Array(Schema.String),
143
+ /**
144
+ * Every file the check read, relative to the workspace root with `/`
145
+ * separators, sorted. Each file appears once, listed under its innermost
146
+ * package even when a wider package's glob also walked it. Assert it is
147
+ * non-empty: a check that read no file proves nothing, and a mistyped
148
+ * `include` shows up here.
149
+ */
150
+ files: Schema.Array(Schema.String),
151
+ /** Every undeclared runtime import, sorted by package, then file, then line, then column. */
152
+ undeclared: Schema.Array(UndeclaredImport)
153
+ }) {
154
+ /** One `package file:line:column undeclared dependency` label per import: `[]` means every runtime import is declared. */
155
+ get violations() {
156
+ return this.undeclared.map((entry) => entry.label);
157
+ }
158
+ };
159
+ /** The runtime sources an undeclared check reads when the caller names none. */
160
+ const DEFAULT_INCLUDE = ["src/**"];
161
+ /** A compiled or source extension a specifier may carry, which resolution swaps for the candidates. */
162
+ const COMPILED_EXTENSION = /\.(?:[cm]?js|[cm]?ts|jsx|tsx)$/u;
163
+ /** Any URL-style scheme (`node:`, `http:`), which is never a package specifier. */
164
+ const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/u;
165
+ const SPACE = /\s/;
166
+ const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
167
+ const byPosition = (a, b) => byCodeUnit(a.file, b.file) || a.line - b.line || a.column - b.column;
168
+ /** Whether the specifier is a bare package specifier: not relative, not absolute, not a subpath import, not a URL. */
169
+ const isBare = (specifier) => !specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("#") && !SCHEME.test(specifier);
170
+ /**
171
+ * The package name a bare specifier names (`effect/Schema` → `effect`,
172
+ * `@org/pkg/x` → `@org/pkg`), or `undefined` for anything that is not one.
173
+ */
174
+ const packageNameOf = (specifier) => {
175
+ if (!isBare(specifier)) return void 0;
176
+ const segments = specifier.split("/");
177
+ if (specifier.startsWith("@")) return segments.length >= 2 && segments[1] !== "" ? `${segments[0]}/${segments[1]}` : void 0;
178
+ const name = segments[0] ?? "";
179
+ return name === "" ? void 0 : name;
180
+ };
181
+ /**
182
+ * The offset just past the `import` or `export` keyword opening the statement
183
+ * the `from` starting at `from` belongs to, walking back over the code view;
184
+ * `undefined` when a `;` or the start of the file comes first, which reads as
185
+ * runtime — the conservative side.
186
+ */
187
+ const statementStart = (code, from) => {
188
+ let j = from - 1;
189
+ while (j >= 0) {
190
+ const char = code[j] ?? "";
191
+ if (char === ";") return void 0;
192
+ if (isIdentifierChar(char)) {
193
+ let start = j;
194
+ while (start >= 0 && isIdentifierChar(code[start])) start--;
195
+ const word = code.slice(start + 1, j + 1);
196
+ if (word === "import" || word === "export") return j + 1;
197
+ j = start;
198
+ continue;
199
+ }
200
+ j--;
201
+ }
202
+ };
203
+ /**
204
+ * Whether the specifier at `literal` sits in a type-only import or re-export
205
+ * (`import type { X } from`, `export type * from`) or in a type-position
206
+ * `import()`, which the compiler erases and so never needs a runtime
207
+ * dependency. A side-effect import, a runtime dynamic `import()`, a
208
+ * `require()` and an `import { type X }` binding list all load at runtime and
209
+ * are not type-only.
210
+ */
211
+ const isTypeOnly = (code, literal) => {
212
+ let j = literal.start - 1;
213
+ while (j >= 0 && SPACE.test(code[j] ?? "")) j--;
214
+ if (code[j] === "(") return isTypePositionImport(code, j);
215
+ if (wordEndingAt(code, j) !== "from") return false;
216
+ let k = j;
217
+ while (k >= 0 && isIdentifierChar(code[k])) k--;
218
+ const start = statementStart(code, k + 1);
219
+ if (start === void 0) return false;
220
+ let m = start;
221
+ while (m < code.length && SPACE.test(code[m] ?? "")) m++;
222
+ let e = m;
223
+ while (e < code.length && isIdentifierChar(code[e])) e++;
224
+ if (code.slice(m, e) !== "type") return false;
225
+ let n = e;
226
+ while (n < code.length && SPACE.test(code[n] ?? "")) n++;
227
+ const next = code[n] ?? "";
228
+ if (next === "{" || next === "*") return true;
229
+ if (!isIdentifierChar(next)) return false;
230
+ let w = n;
231
+ while (w < code.length && isIdentifierChar(code[w])) w++;
232
+ if (code.slice(n, w) !== "from") return true;
233
+ let p = w;
234
+ while (p < code.length && SPACE.test(code[p] ?? "")) p++;
235
+ if (code[p] === "=") return true;
236
+ let q = p;
237
+ while (q < code.length && isIdentifierChar(code[q])) q++;
238
+ return code.slice(p, q) === "from";
239
+ };
240
+ /**
241
+ * The static prefix of a glob: the text before its first pattern character,
242
+ * cut back to the last `/`, so `src/**` walks `src` and a leading `**` walks
243
+ * everything. Over-cutting is safe — the glob still filters every file — so
244
+ * every character any glob syntax could treat as special cuts.
245
+ */
246
+ const globPrefix = (pattern) => {
247
+ const at = pattern.search(/[*?[{\]()!|@]/u);
248
+ const prefix = at === -1 ? pattern : pattern.slice(0, at);
249
+ const slash = prefix.lastIndexOf("/");
250
+ return slash === -1 ? "" : prefix.slice(0, slash);
251
+ };
252
+ /** The distinct walk roots a set of include globs needs, shortest first: a prefix under another walks nothing twice. */
253
+ const includePrefixes = (patterns) => {
254
+ const prefixes = [...new Set(patterns.map(globPrefix))];
255
+ return prefixes.filter((prefix) => prefix === "" || !prefixes.some((other) => other !== prefix && prefix.startsWith(`${other}/`)));
256
+ };
257
+ /**
258
+ * Import-graph checks: properties of what the imports of a set of files
259
+ * reach, which no one-file rule can express.
260
+ *
261
+ * @remarks
262
+ * {@link SourceBoundary} checks one file at a time. Two guards consumers want
263
+ * are properties of the import graph itself:
264
+ *
265
+ * - {@link ImportGraph.reachability} answers "nothing reachable from entry X
266
+ * imports Y". A directory rule approximates that, but a refactor which adds
267
+ * a new static path from the root entry into an allowed directory still
268
+ * passes it; a reachability walk follows the real import edges and reports
269
+ * the chain that reaches the forbidden specifier.
270
+ * - {@link ImportGraph.undeclared} answers "every runtime import is
271
+ * declared". Workspace hoisting resolves an import of a package the
272
+ * manifest never declared, and only a packed install catches that today —
273
+ * far too slow for the edit loop. This check is static and fast: each
274
+ * package's literal specifiers against its own `dependencies`,
275
+ * `peerDependencies` and `optionalDependencies`.
276
+ *
277
+ * Both are static analysis of literal specifiers: static imports,
278
+ * re-exports, and `import()`/`require()` calls whose argument is one string
279
+ * literal. There is no dynamic-import guarantee: a computed specifier
280
+ * (`import("./ui/" + name)`) is invisible to both, and a packed install
281
+ * (`PackedInstall`) remains the proof of what actually resolves at
282
+ * runtime. Both reuse the `SourceBoundary` lexer, so a specifier in a
283
+ * comment, a string, template text or a regex body is never an import, and
284
+ * both refuse to pass vacuously: every result reports what it read.
285
+ *
286
+ * @example
287
+ * ```ts
288
+ * import { NodeServices } from "@effect/platform-node";
289
+ * import { ImportGraph } from "@effected/workspaces/testing";
290
+ * import { Effect } from "effect";
291
+ *
292
+ * // Nothing reachable from the root entry may import ink or react.
293
+ * const walk = ImportGraph.reachability({
294
+ * root: "/repo/packages/ui/src",
295
+ * entries: ["index.ts"],
296
+ * forbid: ["ink", "react"],
297
+ * }).pipe(Effect.provide(NodeServices.layer));
298
+ * ```
299
+ *
300
+ * @public
301
+ */
302
+ var ImportGraph = class {
303
+ constructor() {}
304
+ /**
305
+ * Walk the import graph from `entries` through relative imports and report
306
+ * every forbidden specifier a reached file imports.
307
+ *
308
+ * @remarks
309
+ * The walk is breadth-first, so a violation's chain is a shortest path
310
+ * from an entry, and each file is visited once through its `realPath`, so
311
+ * a symlink loop terminates and a diamond reports one chain. The walk is
312
+ * conservative: it follows every literal specifier, type-only imports
313
+ * included, so a forbidden import only a type reaches is still reported.
314
+ * A bare specifier is checked against `forbid` — the same matcher
315
+ * `SourceBoundary.check` uses — but only walked into when `followPackage`
316
+ * names a file for it. A relative specifier that resolves to no file is
317
+ * reported in `unresolved`, never dropped: the part of the graph behind it
318
+ * is unproven.
319
+ */
320
+ static reachability = Effect.fn("ImportGraph.reachability")(function* (options) {
321
+ const fs = yield* FileSystem.FileSystem;
322
+ const path = yield* Path.Path;
323
+ if (options.entries.length === 0) return yield* Effect.fail(NoEntriesError.make({ root: options.root }));
324
+ const extensions = options.extensions ?? DEFAULT_EXTENSIONS;
325
+ const posix = (relative) => relative.split(path.sep).join("/");
326
+ /** The first candidate that exists as a file, or none. */
327
+ const firstFile = (candidates) => Effect.gen(function* () {
328
+ for (const candidate of candidates) if (yield* fs.exists(candidate)) {
329
+ if ((yield* fs.stat(candidate)).type === "File") return Option.some(candidate);
330
+ }
331
+ return Option.none();
332
+ });
333
+ /** Every file a specifier could name: itself, its stem under each extension, and each extension's index. */
334
+ const candidatesOf = (target) => {
335
+ const stem = target.replace(COMPILED_EXTENSION, "");
336
+ const candidates = [target];
337
+ for (const extension of extensions) candidates.push(stem + extension, path.join(stem, `index${extension}`));
338
+ return candidates;
339
+ };
340
+ /** An entry or a `followPackage` answer as an absolute path: absolute stays, relative joins under the root. */
341
+ const absolute = (given) => path.isAbsolute(given) ? given : path.join(options.root, ...given.split("/"));
342
+ const files = [];
343
+ const offences = [];
344
+ const unresolved = [];
345
+ const reportedUnresolved = /* @__PURE__ */ new Set();
346
+ const visited = /* @__PURE__ */ new Set();
347
+ const queue = [];
348
+ const recordUnresolved = (file, specifier) => {
349
+ const key = `${file}\u0000${specifier}`;
350
+ if (reportedUnresolved.has(key)) return;
351
+ reportedUnresolved.add(key);
352
+ unresolved.push(UnresolvedImport.make({
353
+ file,
354
+ specifier
355
+ }));
356
+ };
357
+ for (const entry of options.entries) {
358
+ const found = yield* firstFile(candidatesOf(absolute(entry)));
359
+ if (Option.isNone(found)) return yield* Effect.fail(EntryNotFoundError.make({
360
+ entry,
361
+ root: options.root
362
+ }));
363
+ const file = posix(path.relative(options.root, found.value));
364
+ queue.push({
365
+ full: found.value,
366
+ file,
367
+ chain: [file]
368
+ });
369
+ }
370
+ for (let i = 0; i < queue.length; i++) {
371
+ const current = queue[i];
372
+ if (current === void 0) continue;
373
+ const real = yield* fs.realPath(current.full);
374
+ if (visited.has(real)) continue;
375
+ visited.add(real);
376
+ files.push(current.file);
377
+ const text = yield* fs.readFileString(current.full);
378
+ for (const offence of SourceBoundary.check(current.file, text, [{ forbidImports: options.forbid }])) offences.push(ReachOffence.make({
379
+ file: offence.file,
380
+ line: offence.line,
381
+ column: offence.column,
382
+ specifier: offence.detail,
383
+ chain: [...current.chain]
384
+ }));
385
+ for (const specifier of SourceBoundary.importSpecifiers(text)) {
386
+ let target;
387
+ if (specifier.startsWith(".")) target = path.resolve(path.dirname(current.full), specifier);
388
+ else if (options.followPackage !== void 0 && isBare(specifier)) {
389
+ const mapped = options.followPackage(specifier);
390
+ if (mapped === void 0) continue;
391
+ target = absolute(mapped);
392
+ } else continue;
393
+ const found = yield* firstFile(candidatesOf(target));
394
+ if (Option.isNone(found)) {
395
+ recordUnresolved(current.file, specifier);
396
+ continue;
397
+ }
398
+ const file = posix(path.relative(options.root, found.value));
399
+ queue.push({
400
+ full: found.value,
401
+ file,
402
+ chain: [...current.chain, file]
403
+ });
404
+ }
405
+ }
406
+ return ReachabilityScan.make({
407
+ files: files.sort(byCodeUnit),
408
+ unresolved: unresolved.sort((a, b) => byCodeUnit(a.file, b.file) || byCodeUnit(a.specifier, b.specifier)),
409
+ offences: offences.sort(byPosition)
410
+ });
411
+ });
412
+ /**
413
+ * Check every workspace package's runtime sources for imports its own
414
+ * manifest does not declare.
415
+ *
416
+ * @remarks
417
+ * For each discovered package, reads the files its `include` globs name
418
+ * (`src/**` by default) and compares every runtime specifier against the
419
+ * package's `dependencies`, `peerDependencies` and `optionalDependencies`.
420
+ * `devDependencies` never declares: an import of one from a runtime source
421
+ * is exactly the hoisting bug this check exists for. `optionalDependencies`
422
+ * does declare: npm and pnpm install one by default, so a packed install
423
+ * resolves the import. A relative,
424
+ * absolute, `#`-subpath or URL-scheme specifier is never a package import;
425
+ * a `node:` specifier is always a built-in, and a bare built-in is one only
426
+ * when `builtins` lists it. A package importing its own name (a
427
+ * self-reference through `exports`) is declared by definition. A file
428
+ * under two discovered packages (a nested package under the root) is
429
+ * checked against its innermost package's manifest only, so a root-wide
430
+ * `include` never double-reports it. Type-only imports are erased by the
431
+ * compiler and never flagged: `import type { X } from`, `export type`
432
+ * re-exports, and an `import()` in a type position (`typeof import("x")`,
433
+ * an annotation, a type alias right-hand side, a generic argument). The
434
+ * position is judged by the construct enclosing the call, not by the one
435
+ * token before it, so a runtime `import("x")` in a typed initializer, an
436
+ * object literal, a ternary branch or a call argument counts like any
437
+ * static import. One `import type from "x"` is runtime too — TypeScript
438
+ * reads it as a default import of a binding named `type`. What the static
439
+ * pass cannot see — a computed specifier — only `PackedInstall` proves.
440
+ */
441
+ static undeclared = Effect.fn("ImportGraph.undeclared")(function* (options = {}) {
442
+ const discovery = yield* WorkspaceDiscovery;
443
+ const fs = yield* FileSystem.FileSystem;
444
+ const path = yield* Path.Path;
445
+ const include = options.include ?? DEFAULT_INCLUDE;
446
+ const globs = yield* GlobSet.compile(include);
447
+ const prefixes = includePrefixes(include);
448
+ const extensions = options.extensions ?? DEFAULT_EXTENSIONS;
449
+ const builtins = new Set(options.builtins ?? []);
450
+ const packages = yield* discovery.listPackages();
451
+ /**
452
+ * Whether `file` sits under the directory `dir`: the relative path from
453
+ * `dir` to `file` stays inside it. A drive or root mismatch surfaces as
454
+ * an absolute relative path and reads as not under.
455
+ */
456
+ const under = (file, dir) => {
457
+ const rel = path.relative(dir, file);
458
+ return rel !== "" && rel !== "." && !path.isAbsolute(rel) && !rel.startsWith(`..${path.sep}`);
459
+ };
460
+ /**
461
+ * The innermost package owning `file`, or `undefined` when no package
462
+ * contains it. Every file belongs to one package — the deepest one — so
463
+ * a glob that walks a nested package never checks that package's files
464
+ * against the outer manifest.
465
+ */
466
+ const ownerOf = (file) => {
467
+ let owner;
468
+ for (const candidate of packages) {
469
+ if (!under(file, candidate.path)) continue;
470
+ if (owner === void 0 || under(candidate.path, owner.path)) owner = candidate;
471
+ }
472
+ return owner;
473
+ };
474
+ const names = [];
475
+ const files = [];
476
+ const undeclared = [];
477
+ for (const pkg of packages) {
478
+ names.push(pkg.name);
479
+ const declared = /* @__PURE__ */ new Set([
480
+ ...Object.keys(pkg.dependencies),
481
+ ...Object.keys(pkg.peerDependencies),
482
+ ...Object.keys(pkg.optionalDependencies)
483
+ ]);
484
+ const seen = /* @__PURE__ */ new Set();
485
+ for (const prefix of prefixes) {
486
+ const walkRoot = prefix === "" ? pkg.path : path.join(pkg.path, ...prefix.split("/"));
487
+ if (!(yield* fs.exists(walkRoot))) continue;
488
+ for (const source of yield* walkSources({
489
+ root: walkRoot,
490
+ extensions
491
+ })) {
492
+ if (ownerOf(source.path) !== pkg) continue;
493
+ const file = prefix === "" ? source.file : `${prefix}/${source.file}`;
494
+ if (seen.has(file) || !globs.matches(file)) continue;
495
+ seen.add(file);
496
+ files.push(pkg.relativePath === "." ? file : `${pkg.relativePath}/${file}`);
497
+ const text = yield* fs.readFileString(source.path);
498
+ const lexed = lex(text);
499
+ const at = locate(text);
500
+ for (const literal of specifierLiterals(lexed)) {
501
+ if (isTypeOnly(lexed.code, literal)) continue;
502
+ const dependency = packageNameOf(literal.value);
503
+ if (dependency === void 0) continue;
504
+ if (builtins.has(dependency) || dependency === pkg.name || declared.has(dependency)) continue;
505
+ undeclared.push(UndeclaredImport.make({
506
+ package: pkg.name,
507
+ file,
508
+ ...at(literal.start),
509
+ specifier: literal.value,
510
+ dependency
511
+ }));
512
+ }
513
+ }
514
+ }
515
+ }
516
+ return UndeclaredScan.make({
517
+ packages: names.sort(byCodeUnit),
518
+ files: files.sort(byCodeUnit),
519
+ undeclared: undeclared.sort((a, b) => byCodeUnit(a.package, b.package) || byCodeUnit(a.file, b.file) || byPosition(a, b))
520
+ });
521
+ });
522
+ };
523
+
524
+ //#endregion
525
+ export { EntryNotFoundError, ImportGraph, NoEntriesError, ReachOffence, ReachabilityScan, UndeclaredImport, UndeclaredScan, UnresolvedImport };
package/README.md CHANGED
@@ -272,7 +272,7 @@ Windows correctness is therefore the operations you pass, and nothing else. Both
272
272
 
273
273
  ## Repo-shape checks (`@effected/workspaces/testing`)
274
274
 
275
- A monorepo's shape drifts in ways no unit test sees: a package that should stay platform-free starts reading `process`, a low-level package grows an edge up into an application, or a bin resolves fine inside the workspace and is missing once installed from its tarball. The `@effected/workspaces/testing` subpath holds three checks you run from your own test suite to catch each one. The main entry never re-exports it, so a consumer of `.` never loads a scanner or package-manager orchestration it does not use.
275
+ A monorepo's shape drifts in ways no unit test sees: a package that should stay platform-free starts reading `process`, a root entry grows a static path into a module it must never load, an import resolves only because the workspace hoists it, a low-level package grows an edge up into an application, or a bin resolves fine inside the workspace and is missing once installed from its tarball. The `@effected/workspaces/testing` subpath holds four checks you run from your own test suite to catch each one. The main entry never re-exports it, so a consumer of `.` never loads a scanner or package-manager orchestration it does not use.
276
276
 
277
277
  Every check refuses to pass vacuously, so every example below pairs "nothing wrong" with "something was checked".
278
278
 
@@ -408,6 +408,58 @@ The scanner is a lexer, not a type checker. Its known limits:
408
408
  - JSX text reads as code, which is why `.tsx` and `.jsx` are not scanned by default.
409
409
  - `forbidImports: ["node:*"]` matches only the `node:` spelling, not a bare `"fs"`. To forbid both, spread Node's list in the test file: `{ forbidImports: ["node:*", ...builtinModules] }`, with `builtinModules` from `node:module`. That also forbids npm packages named like a built-in (`events`, `buffer`).
410
410
 
411
+ ### Import graph
412
+
413
+ Two guards are properties of the import graph rather than of one file, so no per-file rule can express them. `ImportGraph.reachability` walks from entry files through relative imports and reports every forbidden specifier a reached file imports, with the chain of files that reaches it. A directory rule approximates reachability, but a refactor that keeps the rule and adds a new static path from the root entry into the allowed directory passes it; the walk follows the real edges. `ImportGraph.undeclared` reports every runtime import whose package the owning package's manifest does not declare in `dependencies`, `peerDependencies` or `optionalDependencies` — what workspace hoisting hides and only a packed install caught before, far too slow for the edit loop.
414
+
415
+ ```ts
416
+ // __test__/graph.test.ts, one level below the workspace root
417
+ import { builtinModules } from "node:module";
418
+ import { dirname, join, resolve } from "node:path";
419
+ import { fileURLToPath } from "node:url";
420
+ import { NodeServices } from "@effect/platform-node";
421
+ import { assert, describe, layer } from "@effect/vitest";
422
+ import { Workspaces } from "@effected/workspaces";
423
+ import { ImportGraph } from "@effected/workspaces/testing";
424
+ import { Effect, Layer } from "effect";
425
+
426
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
427
+ const Live = Workspaces.layer({ cwd: ROOT }).pipe(Layer.provideMerge(NodeServices.layer));
428
+
429
+ describe("import graph", () => {
430
+ layer(Live)((it) => {
431
+ it.effect("nothing reachable from the root entry loads ink or react", () =>
432
+ Effect.gen(function* () {
433
+ const walk = yield* ImportGraph.reachability({
434
+ root: join(ROOT, "packages/ui/src"),
435
+ entries: ["index.ts"],
436
+ forbid: ["ink", "react"],
437
+ });
438
+ assert.isNotEmpty(walk.files, "the walk reached the tree");
439
+ assert.deepStrictEqual(walk.unresolved, [], "every relative import resolved");
440
+ assert.deepStrictEqual(walk.violations, []);
441
+ }),
442
+ );
443
+
444
+ it.effect("every runtime import is declared", () =>
445
+ Effect.gen(function* () {
446
+ const scan = yield* ImportGraph.undeclared({ builtins: builtinModules });
447
+ assert.isNotEmpty(scan.files, "the check read sources");
448
+ assert.deepStrictEqual(scan.violations, []);
449
+ }),
450
+ );
451
+ });
452
+ });
453
+ ```
454
+
455
+ A violation names the path that reaches it: `src/ui/ink.ts:1:24 ink via index.ts -> ui/shell.ts -> src/ui/ink.ts`. An undeclared import names the package, the file and the specifier: `@my-org/app src/main.ts:7:21 undeclared left-pad`.
456
+
457
+ The walk is breadth-first, visits each file once through its realpath — a symlink loop terminates, a diamond reports one shortest chain — and it is conservative: it follows every literal specifier, type-only imports included, so a forbidden import only a type reaches is still reported. A relative import that resolves to no file lands in `unresolved`, never dropped silently: the graph behind it is unproven, so assert `unresolved` is empty or exactly the generated files you expect. An entry that resolves to no file fails `EntryNotFoundError`, and an empty entry list fails `NoEntriesError`: the walk never proves nothing silently. `followPackage` is the extension point for workspace-package traversal: it is called with each bare specifier and answers the file to continue from, or `undefined` to stop there; a stale mapping lands in `unresolved` too.
458
+
459
+ The undeclared check counts `dependencies`, `peerDependencies` and `optionalDependencies` as declared, and never `devDependencies`: an import of one from a runtime source is exactly the bug. An `optionalDependency` does declare — npm and pnpm install one by default, so a packed install resolves the import. It never flags a `node:` specifier, a relative or absolute specifier, a `#` subpath import, or a package's self-reference through its own name. A bare built-in is flagged unless the caller lists it: spread `builtinModules` from `node:module` in the test file, since no built-in list ships here — one would drift with Node releases, the same convention `forbidImports` uses. Type-only imports are erased by the compiler and never flagged: `import type { X } from`, `export type` re-exports, and an `import()` in a type position (`typeof import("x")`, an annotation, a type alias right-hand side, a generic argument). The position is judged by the construct enclosing the call rather than by the one token before it, so a runtime `import("x")` in a typed initializer, an object literal, a ternary branch or a call argument is still reported. One `import type from "x"` is not type-only — TypeScript reads it as a runtime default import of a binding named `type`. `include` globs (default `src/**`, matched against the package-relative path) name the runtime sources; each package is walked from each glob's static prefix, so a build output or a test directory is never walked under the default, and test files importing devDependencies are out of scope by design. A file under two discovered packages belongs to its innermost one: a root-wide glob like `["**/*.ts"]` walks the whole repository, but a nested package's files are checked only against that package's manifest, and each file is listed once.
460
+
461
+ Both checks are static analysis of literal specifiers — there is no dynamic-import guarantee: a computed specifier (`import("./ui/" + name)`) is invisible — and `PackedInstall` remains the proof of what actually resolves at runtime. Both reuse the `SourceBoundary` lexer, so a specifier in a comment, a string, template text or a regex body is never an import.
462
+
411
463
  ### Packed install
412
464
 
413
465
  `PackedInstall.run` packs a carrier and every workspace package it needs, then installs the carrier into a fresh project outside the workspace under each package manager that answers `--version`, and checks every expected bin is linked and executable. It carries the traps each manager sets: the parent run's `npm_*` and `pnpm_config_*` context is scrubbed, pnpm's overrides go in `pnpm-workspace.yaml`, Yarn Berry gets the `node-modules` linker, `packageManager` is pinned to the probed version, and lifecycle scripts are skipped. By default it packs each package's built `dist/prod/npm/pkg`, the artifact a release publishes; build first.
@@ -563,7 +615,7 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
563
615
  - `ReleaseTag` / `TrackingTag` — release-tag formatting (`ReleaseTag.single` / `.scoped`, strict SemVer by default with no `v` prefix) and the floating major/minor alias derivation GitHub Actions-style consumers expect (`v1`, `v1.2`), plus `classifyTag` to tell a release tag from a tracking alias.
564
616
  - `VersioningStrategy` — classify a workspace as `single`, `fixed-group` or `independent` from package names and fixed groups, or detect it live against `PublishabilityDetector`, and produce the release tags for a batch with `tagsFor`.
565
617
  - `findWorkspaceRootSync` / `getWorkspacePackagesSync` — the synchronous escape hatch for config-time callers that cannot await, over file and path operations you supply.
566
- - `@effected/workspaces/testing` — a third entry point holding the repo-shape checks: `WorkspaceLayering` and `LayerPolicy` (the package graph against a committed `layers.json`), `SourceBoundary` (a lexer-backed scanner for `process` reads, forbidden imports and console writes, with shipped positive controls) and `PackedInstall` (the carrier's bins installed from packed tarballs under every available package manager).
618
+ - `@effected/workspaces/testing` — a third entry point holding the repo-shape checks: `WorkspaceLayering` and `LayerPolicy` (the package graph against a committed `layers.json`), `SourceBoundary` (a lexer-backed scanner for `process` reads, forbidden imports and console writes, with shipped positive controls), `ImportGraph` (the two import-graph checks: reachability from entry files to a forbidden specifier, with the chain that reaches it, and undeclared runtime imports against each package's manifest) and `PackedInstall` (the carrier's bins installed from packed tarballs under every available package manager).
567
619
  - `@effected/workspaces/node-sync` — a second entry point holding the Node bindings for those operations (`nodeFileSystem`, `nodePath` and the `nodeSyncOps` bag), kept off the main entry so `node:*` never reaches a consumer that supplies its own. `nodeFileSystem` implements the optional `readDirectoryWithTypes` fast path, so the bindings enumerate a workspace in one `readdirSync` per directory.
568
620
 
569
621
  ## License