@drkmttr/adhere 0.18.1 → 0.23.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.
Files changed (43) hide show
  1. package/README.md +62 -1575
  2. package/package.json +18 -15
  3. package/presets/README.md +140 -0
  4. package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
  5. package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
  6. package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
  7. package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
  8. package/skills/adhere/SKILL.md +194 -29
  9. package/skills/adhere-fix/SKILL.md +1 -1
  10. package/skills/adhere-setup/SKILL.md +60 -26
  11. package/src/cli.ts +334 -36
  12. package/src/config.ts +173 -142
  13. package/src/create/agents.ts +149 -0
  14. package/src/create/ask.ts +363 -0
  15. package/src/create/examples.ts +94 -0
  16. package/src/create/look.ts +191 -0
  17. package/src/create/oxlint-trial.ts +464 -0
  18. package/src/create/oxlint.ts +1012 -0
  19. package/src/create/trial-review.ts +562 -0
  20. package/src/create/trial.ts +611 -0
  21. package/src/create/wizard.ts +466 -0
  22. package/src/index.ts +2 -0
  23. package/src/init.ts +171 -41
  24. package/src/install.ts +296 -94
  25. package/src/instructions.ts +98 -0
  26. package/src/markdown.ts +15 -11
  27. package/src/oxlint/catalog.json +7844 -0
  28. package/src/oxlint/catalog.ts +72 -0
  29. package/src/oxlint/shared.ts +140 -0
  30. package/src/readme-guide.ts +431 -0
  31. package/src/readme.ts +291 -0
  32. package/src/rules.ts +42 -45
  33. package/src/services/AdhereConfig.ts +67 -21
  34. package/src/services/AuditCache.ts +176 -169
  35. package/src/services/Credentials.ts +14 -8
  36. package/src/services/Jev.reads.test.ts +22 -0
  37. package/src/services/Jev.ts +35 -13
  38. package/src/services/SourceWalker.ts +12 -5
  39. package/src/wording.ts +4 -4
  40. package/src/workflows/audit.ts +85 -14
  41. package/src/workflows/format.ts +98 -1
  42. package/src/workspaces.test.ts +37 -1
  43. package/src/workspaces.ts +121 -12
@@ -40,7 +40,13 @@ import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
40
40
  import { priceOf } from "#pricing.ts";
41
41
  import { applicableRules, judgesFile, shownId } from "#rules.ts";
42
42
  import { suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
43
- import { workspacePackagesIn } from "#workspaces.ts";
43
+ import {
44
+ dependentFilesIn,
45
+ importSpecifiersIn,
46
+ packageOf,
47
+ workspacePackagesIn,
48
+ } from "#workspaces.ts";
49
+ import { relative, sep } from "node:path";
44
50
  import { type Crypto, Duration, Effect, Option, Record, Result, Semaphore, Stream } from "effect";
45
51
 
46
52
  export interface Finding {
@@ -125,19 +131,24 @@ const LAYOUT = "sections";
125
131
 
126
132
  /**
127
133
  * What a judgment depends on besides the file: the model, how the file is
128
- * laid out, and the questions asked for the rule, which carry the rule: its
129
- * judge question, and its matchers' when it has any. An edited rule or
130
- * matcher re-judges the rule, and a question asked differently re-judges
131
- * every rule. So does an edited `appendState`, by its source; what the hook
132
- * reads outside the file is not known until it runs, so a change there does
133
- * not. Shared read context and related files do not invalidate judgments.
134
+ * laid out, what the config has Jev read, and the questions asked for the
135
+ * rule, which carry the rule: its judge question, and its matchers' when it
136
+ * has any. An edited rule or matcher re-judges the rule, and a question asked
137
+ * differently, or other reads, re-judge every rule. So does an edited
138
+ * `appendState`, by its source; what the hook reads outside the file is not
139
+ * known until it runs, so a change there does not. Options set differently
140
+ * re-judge their rule: decoded, a struct's keys come in its schema's order,
141
+ * however the config writes them. What the reads find, such as related
142
+ * files, does not invalidate judgments.
134
143
  */
135
- const fingerprintOf = (model: string, rule: Rule) => {
144
+ const fingerprintOf = (model: string, reads: string, rule: Rule) => {
136
145
  const matchers = matcherQuestions("", rule).map(([, question]) => question);
137
146
  const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
138
147
  const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
148
+ const options =
149
+ rule.optionValues === undefined ? "" : `\u0000${JSON.stringify(rule.optionValues)}`;
139
150
  return sha256(
140
- `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
151
+ `${model}\u0000${LAYOUT}${reads}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${options}`,
141
152
  );
142
153
  };
143
154
 
@@ -368,9 +379,14 @@ const hasLocatedFindings = (plan: FilePlan): boolean =>
368
379
  );
369
380
  });
370
381
 
382
+ /** At most this many of a file's dependent files are listed; the rest are counted. */
383
+ const MAX_DEPENDENT_FILES = 50;
384
+
371
385
  export interface PlanOptions {
372
386
  /** Select at most this many files needing judge or cached-locate requests; zero plans only. */
373
387
  readonly limit?: number | undefined;
388
+ /** The checkout Jev's paths are relative to. Defaults to the working directory. */
389
+ readonly root?: string | undefined;
374
390
  }
375
391
 
376
392
  export const planAudit = (
@@ -386,6 +402,7 @@ export const planAudit = (
386
402
  const workspacePackages = config.reads.includes("workspacePackages")
387
403
  ? (config.workspacePackages ?? {})
388
404
  : undefined;
405
+ const catalog = config.workspacePackages ?? {};
389
406
  const nativeReads: SourceReadRequest = {
390
407
  symbols: config.typeAware,
391
408
  references: config.typeAware,
@@ -395,6 +412,40 @@ export const planAudit = (
395
412
  };
396
413
  const walker = yield* SourceWalker;
397
414
  const files = yield* walker.files;
415
+ // Each file's path from the checkout, `/`-separated, as Jev reads it.
416
+ const root = options.root ?? process.cwd();
417
+ const fromRoot = new Map(
418
+ files.map((file) => [file.path, relative(root, file.path).split(sep).join("/")]),
419
+ );
420
+ // Only the specifiers stay in memory: each file is read again when it is planned.
421
+ const dependentFiles = config.reads.includes("dependents")
422
+ ? dependentFilesIn(
423
+ new Map(
424
+ yield* Effect.forEach(
425
+ files,
426
+ (file) =>
427
+ Effect.map(
428
+ walker.read(file),
429
+ ({ lines }) =>
430
+ [fromRoot.get(file.path)!, importSpecifiersIn(lines.join("\n"))] as const,
431
+ ),
432
+ { concurrency: 8 },
433
+ ),
434
+ ),
435
+ )
436
+ : undefined;
437
+ const dependentsOf = (file: SourceFile) => {
438
+ const at = fromRoot.get(file.path)!;
439
+ const name = packageOf(at, catalog);
440
+ const importers = dependentFiles?.get(at) ?? [];
441
+ return {
442
+ packages: (name === undefined ? undefined : config.workspaceDependents?.[name]) ?? {},
443
+ files: importers.slice(0, MAX_DEPENDENT_FILES),
444
+ ...(importers.length > MAX_DEPENDENT_FILES
445
+ ? { omittedFiles: importers.length - MAX_DEPENDENT_FILES }
446
+ : {}),
447
+ };
448
+ };
398
449
 
399
450
  // Each preset's rule, with the preset it came from, for its findings to name.
400
451
  const presetOfRule = new Map(
@@ -416,11 +467,23 @@ export const planAudit = (
416
467
  );
417
468
 
418
469
  // Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
470
+ // The reads that take effect key every judgment; code-only keeps the key it always had.
471
+ // includeComments shapes other files' `references` excerpts, which the target's hash misses.
472
+ const reads = [
473
+ ...(workspacePackages === undefined ? [] : ["workspacePackages"]),
474
+ ...(dependentFiles === undefined ? [] : ["dependents"]),
475
+ ...(config.typeAware
476
+ ? [
477
+ `symbols:${config.maxSymbolLength}:${config.maxSymbolDocumentationLength}:${config.includeComments === true}`,
478
+ ]
479
+ : []),
480
+ ];
481
+ const readsKey = reads.length === 0 ? "" : `\u0000${reads.join(",")}`;
419
482
  const preparedRules = new Map<Rule, PreparedRule>(
420
483
  yield* Effect.forEach(
421
484
  new Set(config.scopedRules?.map(({ rule }) => rule) ?? Object.values(config.rules)),
422
485
  (rule) =>
423
- Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
486
+ Effect.map(fingerprintOf(config.model, readsKey, rule), (fingerprint) => {
424
487
  const preset = presetOfRule.get(rule);
425
488
  return [
426
489
  rule,
@@ -440,10 +503,12 @@ export const planAudit = (
440
503
  const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
441
504
  const rules = configuredRules(file, suppressions);
442
505
  const code = lines.join("\n");
443
- const readState: ReadState =
444
- workspacePackages === undefined
506
+ const readState: ReadState = {
507
+ ...(workspacePackages === undefined
445
508
  ? {}
446
- : { workspacePackages: workspacePackagesIn(code, workspacePackages) };
509
+ : { workspacePackages: workspacePackagesIn(code, workspacePackages) }),
510
+ ...(dependentFiles === undefined ? {} : { dependents: dependentsOf(file) }),
511
+ };
447
512
  // Hashed even when unjudged or skipped, so a prune keeps what other runs know of its content.
448
513
  const hash = yield* sha256(code);
449
514
  const prepared = Record.map(rules, (rule) => preparedRules.get(rule)!);
@@ -802,7 +867,13 @@ export const executeAudit = (
802
867
  const room = questionRoom(file.lines, readState);
803
868
  const sentSamples = Record.filter(sampled, (_, id) => {
804
869
  const k = pending[id];
805
- return k !== undefined && tokensOf(linterQuestion(k.rule)) <= room;
870
+ if (k === undefined) return false;
871
+ // A rule with options asks beside them, in a request of its own.
872
+ const left =
873
+ k.rule.optionValues === undefined
874
+ ? room
875
+ : questionRoom(file.lines, { ...readState, options: k.rule.optionValues });
876
+ return tokensOf(linterQuestion(k.rule)) <= left;
806
877
  });
807
878
  const sampleIds = Object.keys(sentSamples);
808
879
  const { probabilities, linter, matchers }: Judged = isEmpty(pending)
@@ -89,6 +89,10 @@ const hanging = (label: Span, lines: ReadonlyArray<Line>): ReadonlyArray<Line> =
89
89
  const highlighted = (code: string): ReadonlyArray<Line> =>
90
90
  tokenize(code).map((tokens) => tokens.map(({ text, kind }) => span(text, kind)));
91
91
 
92
+ /** Code as lines of text, highlighted as a report highlights it when `color`. */
93
+ export const highlightedLines = (code: string, color: boolean): ReadonlyArray<string> =>
94
+ highlighted(code).map((line) => serialize(line, color));
95
+
92
96
  /** Code as highlighted lines, without the blank lines at either end. */
93
97
  const codeLines = (code: string): ReadonlyArray<Line> => {
94
98
  const lines = highlighted(code);
@@ -210,7 +214,7 @@ const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line
210
214
  /**
211
215
  * The warning on a finding whose file may not show enough to check its rule,
212
216
  * which its context score in the header says how far, and help: how to add
213
- * what the code relies on as a note Jev reads, in the form the README asks for.
217
+ * what the code relies on as a note Jev reads, in the form the docs ask for.
214
218
  */
215
219
  const insufficient = (finding: Finding, sufficiencyThreshold: number): ReadonlyArray<Line> =>
216
220
  !lacksContext(finding, sufficiencyThreshold)
@@ -398,6 +402,99 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
398
402
  ].map((line) => serialize(line, options.color === true));
399
403
  };
400
404
 
405
+ /** The complete audit result, with the same rule ids and relative paths as the text report. */
406
+ export const renderJson = (result: AuditResult, options: RenderOptions = {}): string =>
407
+ JSON.stringify(
408
+ {
409
+ ...result,
410
+ threshold: options.threshold ?? DEFAULT_THRESHOLD,
411
+ sufficiencyThreshold: options.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD,
412
+ ...(options.provider === undefined ? {} : { provider: options.provider }),
413
+ findings: result.findings.map((finding) => ({
414
+ ...finding,
415
+ rule: shownId({ id: finding.rule, preset: finding.preset }),
416
+ file: displayPath(finding.file, options.root),
417
+ })),
418
+ blocked: result.blocked.map((blocked) => ({
419
+ ...blocked,
420
+ file: displayPath(blocked.file, options.root),
421
+ })),
422
+ },
423
+ null,
424
+ 2,
425
+ );
426
+
427
+ const markdownText = (text: string): string => text.replace(/[\\`*_[\]<>#]/g, "\\$&");
428
+
429
+ /** A fence longer than any backtick run in the code, including template literals. */
430
+ const fenced = (code: string): string => {
431
+ const fence = "`".repeat(
432
+ Math.max(3, ...Array.from(code.matchAll(/`+/g), ([run]) => run.length + 1)),
433
+ );
434
+ return `${fence}typescript\n${code}\n${fence}`;
435
+ };
436
+
437
+ /** All findings grouped by rule, followed by the run summary once. */
438
+ export const renderMarkdown = (result: AuditResult, options: RenderOptions = {}): string => {
439
+ const sufficiencyThreshold = options.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD;
440
+ const groups = Map.groupBy(result.findings, (finding) =>
441
+ shownId({ id: finding.rule, preset: finding.preset }),
442
+ );
443
+ return [
444
+ ...Array.from(groups, ([id, findings]) =>
445
+ [
446
+ `# ${markdownText(id)}`,
447
+ counted(findings.length, "finding", "findings"),
448
+ ...findings.flatMap((finding) => {
449
+ const lines = finding.lines ?? finding.section;
450
+ const location = `${displayPath(finding.file, options.root)}:${lines.first}${lines.last === lines.first ? "" : `–${lines.last}`}`;
451
+ return [
452
+ `## ${markdownText(location)}`,
453
+ finding.description,
454
+ `Level: **${finding.level}** · Confidence: ${finding.probability} · Threshold: ${finding.threshold}${finding.context === undefined ? "" : ` · Context: ${finding.context}`}`,
455
+ ...(lacksContext(finding, sufficiencyThreshold)
456
+ ? [
457
+ `> Warning: this file may not show enough to check this rule (context below ${sufficiencyThreshold}). Add missing context with an \`@adhere\` note.`,
458
+ ]
459
+ : []),
460
+ `Excerpt starting at line ${finding.excerpt.start}:`,
461
+ fenced(finding.excerpt.lines.join("\n")),
462
+ ...[finding.examples.good, finding.examples.bad].flatMap((example) =>
463
+ example === undefined ? [] : [`### ${example.word}`, fenced(example.code.trim())],
464
+ ),
465
+ ];
466
+ }),
467
+ ].join("\n\n"),
468
+ ),
469
+ "## Run summary",
470
+ ...summary(result).map((line) => serialize(line, false)),
471
+ ...blockedFiles(result, options.root)
472
+ .filter((line) => line.length > 0)
473
+ .map((line) => serialize(line, false)),
474
+ ].join("\n\n");
475
+ };
476
+
477
+ /** One file per violated rule in each requested format, with run counts retained as context. */
478
+ export const renderRuleReports = (
479
+ result: AuditResult,
480
+ formats: { readonly json: boolean; readonly md: boolean },
481
+ options: RenderOptions = {},
482
+ ): ReadonlyMap<string, string> => {
483
+ const groups = Map.groupBy(result.findings, (finding) =>
484
+ shownId({ id: finding.rule, preset: finding.preset }),
485
+ );
486
+ return new Map(
487
+ Array.from(groups).flatMap(([id, findings]): Array<[string, string]> => {
488
+ const name = encodeURIComponent(id);
489
+ const report = { ...result, findings };
490
+ const files: Array<[string, string]> = [];
491
+ if (formats.json) files.push([`${name}.json`, `${renderJson(report, options)}\n`]);
492
+ if (formats.md) files.push([`${name}.md`, `${renderMarkdown(report, options)}\n`]);
493
+ return files;
494
+ }),
495
+ );
496
+ };
497
+
401
498
  /**
402
499
  * What a run will do, before it asks: the files and rules found, the checks
403
500
  * between them and how many the cache answers, then what judging the rest
@@ -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];