@drkmttr/adhere 0.18.1 → 0.21.2

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.
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from "vite-plus/test";
2
- import { workspacePackagesIn } from "./workspaces.ts";
2
+ import {
3
+ dependentFilesIn,
4
+ importSpecifiersIn,
5
+ packageOf,
6
+ workspacePackagesIn,
7
+ } from "./workspaces.ts";
3
8
 
4
9
  const catalog = {
5
10
  "@repo/foo": "packages/foo",
@@ -71,3 +76,34 @@ describe("file-local workspace packages", () => {
71
76
  expect(workspacePackagesIn("import(`@repo/foo/${name}`)", catalog)).toEqual({});
72
77
  });
73
78
  });
79
+
80
+ describe("dependent files", () => {
81
+ it("resolves relative specifiers to known files as written, by emitted extension, added extension, or index", () => {
82
+ const dependents = dependentFilesIn(
83
+ new Map([
84
+ ["src/a.ts", importSpecifiersIn('import { b } from "./b.js"; export * from "./lib";')],
85
+ ["src/b.ts", importSpecifiersIn('import "../util"; import x from "pkg/./b";')],
86
+ [
87
+ "src/lib/index.ts",
88
+ importSpecifiersIn('const self = require("./index.ts"); import("..");'),
89
+ ],
90
+ ["src/c.tsx", importSpecifiersIn('import { b } from "./b"; import "./missing";')],
91
+ ["util.ts", []],
92
+ ["src/index.ts", []],
93
+ ]),
94
+ );
95
+ expect(Object.fromEntries(dependents)).toEqual({
96
+ "src/b.ts": ["src/a.ts", "src/c.tsx"],
97
+ "src/lib/index.ts": ["src/a.ts"],
98
+ "util.ts": ["src/b.ts"],
99
+ "src/index.ts": ["src/lib/index.ts"],
100
+ });
101
+ });
102
+
103
+ it("places a file in the deepest package directory that holds it", () => {
104
+ const catalog = { outer: "packages/outer", inner: "packages/outer/inner", other: "packages/o" };
105
+ expect(packageOf("packages/outer/inner/src/a.ts", catalog)).toBe("inner");
106
+ expect(packageOf("packages/outer/src/a.ts", catalog)).toBe("outer");
107
+ expect(packageOf("packages/outer-x/a.ts", catalog)).toBeUndefined();
108
+ });
109
+ });
package/src/workspaces.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Effect, FileSystem, Path } from "effect";
2
+ import { posix } from "node:path";
2
3
  import { scan, type Token } from "#highlight.ts";
3
4
 
4
5
  /** A workspace's packages: each one's name, with its directory from the root. */
@@ -38,15 +39,12 @@ const specifierOf = (token: Token | undefined, templates = false): string | unde
38
39
  };
39
40
 
40
41
  /**
41
- * Only packages named by this file's literal imports, type imports, re-exports,
42
- * or import/require calls. Tokens keep comments, strings, regexes, and templates
43
- * opaque; computed modules and imports inside template substitutions are not resolved.
44
- * Subpaths identify their canonical package, without walking the global catalog.
42
+ * Every module specifier named by this file's literal imports, type imports,
43
+ * re-exports, or import/require calls, once each, in order. Tokens keep comments,
44
+ * strings, regexes, and templates opaque; computed modules and imports inside
45
+ * template substitutions are not resolved.
45
46
  */
46
- export const workspacePackagesIn = (
47
- code: string,
48
- catalog: WorkspacePackages,
49
- ): WorkspacePackages => {
47
+ export const importSpecifiersIn = (code: string): ReadonlyArray<string> => {
50
48
  const tokens = scan(code).flatMap((token) => {
51
49
  if (token.kind === "comment" || token.text.trim() === "") return [];
52
50
  // Highlighting groups punctuation; module clauses need individual delimiters.
@@ -54,11 +52,9 @@ export const workspacePackagesIn = (
54
52
  ? [...token.text].map((text): Token => ({ text }))
55
53
  : [token];
56
54
  });
57
- const names = new Set<string>();
55
+ const specifiers = new Set<string>();
58
56
  const keep = (specifier: string | undefined) => {
59
- if (specifier === undefined || specifier.startsWith(".") || specifier.startsWith("/")) return;
60
- const name = specifier.split("/", specifier.startsWith("@") ? 2 : 1).join("/");
61
- if (Object.hasOwn(catalog, name)) names.add(name);
57
+ if (specifier !== undefined) specifiers.add(specifier);
62
58
  };
63
59
  for (const [index, token] of tokens.entries()) {
64
60
  if (tokens[index - 1]?.text === ".") continue;
@@ -91,6 +87,23 @@ export const workspacePackagesIn = (
91
87
  if (part.kind === "keyword" && !["type", "as", "from"].includes(part.text)) break;
92
88
  }
93
89
  }
90
+ return [...specifiers];
91
+ };
92
+
93
+ /**
94
+ * Only packages this file imports, by `importSpecifiersIn`. Subpaths identify
95
+ * their canonical package, without walking the global catalog.
96
+ */
97
+ export const workspacePackagesIn = (
98
+ code: string,
99
+ catalog: WorkspacePackages,
100
+ ): WorkspacePackages => {
101
+ const names = new Set<string>();
102
+ for (const specifier of importSpecifiersIn(code)) {
103
+ if (specifier.startsWith(".") || specifier.startsWith("/")) continue;
104
+ const name = specifier.split("/", specifier.startsWith("@") ? 2 : 1).join("/");
105
+ if (Object.hasOwn(catalog, name)) names.add(name);
106
+ }
94
107
  return Object.fromEntries([...names].sort().map((name) => [name, catalog[name]!]));
95
108
  };
96
109
 
@@ -230,3 +243,99 @@ export const workspacePackagesOf = Effect.fn("workspacePackagesOf")(function* (r
230
243
  named.flat().sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
231
244
  ) as WorkspacePackages;
232
245
  });
246
+
247
+ const DEPENDENCY_FIELDS = [
248
+ "dependencies",
249
+ "devDependencies",
250
+ "peerDependencies",
251
+ "optionalDependencies",
252
+ ] as const;
253
+
254
+ /**
255
+ * For each workspace package, the other workspace packages whose package.json
256
+ * depends on it, by name with directory, sorted. A package no other one names
257
+ * is left out.
258
+ */
259
+ export const workspaceDependentsOf = Effect.fn("workspaceDependentsOf")(function* (
260
+ root: string,
261
+ catalog: WorkspacePackages,
262
+ ) {
263
+ const fs = yield* FileSystem.FileSystem;
264
+ const path = yield* Path.Path;
265
+ const dependents = new Map<string, Map<string, string>>();
266
+ // Sequential over the sorted catalog, so each package's dependents are inserted sorted.
267
+ yield* Effect.forEach(Object.entries(catalog), ([name, directory]) =>
268
+ Effect.map(
269
+ fs
270
+ .readFileString(path.join(root, directory, "package.json"))
271
+ .pipe(Effect.orElseSucceed(() => "")),
272
+ (text) => {
273
+ const manifest = parsed(text) as Readonly<Record<string, unknown>> | undefined;
274
+ for (const field of DEPENDENCY_FIELDS) {
275
+ const named = manifest?.[field];
276
+ if (named === null || typeof named !== "object") continue;
277
+ for (const dependency of Object.keys(named)) {
278
+ if (dependency === name || !Object.hasOwn(catalog, dependency)) continue;
279
+ const found = dependents.get(dependency) ?? new Map<string, string>();
280
+ dependents.set(dependency, found.set(name, directory));
281
+ }
282
+ }
283
+ },
284
+ ),
285
+ );
286
+ return Object.fromEntries(
287
+ [...dependents].map(([name, found]) => [name, Object.fromEntries(found)]),
288
+ ) as Readonly<Record<string, WorkspacePackages>>;
289
+ });
290
+
291
+ /** TypeScript source extensions an import resolves to, in the order tried. */
292
+ const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const;
293
+ /** The source extensions an emitted extension in a specifier stands for. */
294
+ const EMITTED: Readonly<Record<string, ReadonlyArray<string>>> = {
295
+ ".js": [".ts", ".tsx"],
296
+ ".jsx": [".tsx"],
297
+ ".mjs": [".mts"],
298
+ ".cjs": [".cts"],
299
+ };
300
+
301
+ /** The paths a relative specifier can name, most specific first. */
302
+ const candidatesOf = (target: string): ReadonlyArray<string> => {
303
+ const extension = posix.extname(target);
304
+ const emitted = EMITTED[extension] ?? [];
305
+ const stem = target.slice(0, target.length - extension.length);
306
+ return [
307
+ target,
308
+ ...emitted.map((source) => `${stem}${source}`),
309
+ ...SOURCE_EXTENSIONS.map((source) => `${target}${source}`),
310
+ ...SOURCE_EXTENSIONS.map((source) => `${target}/index${source}`),
311
+ ];
312
+ };
313
+
314
+ /**
315
+ * For each file, the files that import it by a relative specifier, sorted.
316
+ * Keys and values are `/`-separated paths from one root, as `imports` gives
317
+ * them; a specifier resolves only to a file `imports` has, by the first of its
318
+ * candidates found: as written, with `.js` as `.ts`, with a source extension
319
+ * added, then as a directory's index. Package and path-alias imports do not count.
320
+ */
321
+ export const dependentFilesIn = (
322
+ imports: ReadonlyMap<string, ReadonlyArray<string>>,
323
+ ): ReadonlyMap<string, ReadonlyArray<string>> => {
324
+ const dependents = new Map<string, Set<string>>();
325
+ for (const [importer, specifiers] of imports) {
326
+ for (const specifier of specifiers) {
327
+ if (specifier !== "." && specifier !== ".." && !/^\.\.?\//.test(specifier)) continue;
328
+ const target = posix.join(posix.dirname(importer), specifier);
329
+ const found = candidatesOf(target).find((candidate) => imports.has(candidate));
330
+ if (found === undefined || found === importer) continue;
331
+ dependents.set(found, (dependents.get(found) ?? new Set()).add(importer));
332
+ }
333
+ }
334
+ return new Map([...dependents].map(([file, found]) => [file, [...found].sort()]));
335
+ };
336
+
337
+ /** The workspace package a `/`-separated path from the root lies in: the one with the deepest directory holding it. */
338
+ export const packageOf = (file: string, catalog: WorkspacePackages): string | undefined =>
339
+ Object.entries(catalog)
340
+ .filter(([, directory]) => file.startsWith(`${directory}/`))
341
+ .sort(([, a], [, b]) => b.length - a.length)[0]?.[0];