@effected/workspaces 0.32.1 → 0.33.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 +525 -0
- package/README.md +54 -2
- package/SourceBoundary.js +15 -40
- package/internal/importPosition.js +642 -0
- package/internal/sourceText.js +1 -1
- package/internal/sourceWalk.js +58 -0
- package/package.json +12 -12
- package/testing.d.ts +418 -104
- package/testing.js +3 -2
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
|
|
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
|