@drkmttr/adhere 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "private": false,
5
5
  "description": "Lints a repository against the rules in .adhere/, judged by Jev.",
6
6
  "license": "MIT",
@@ -68,10 +68,10 @@
68
68
  }
69
69
  },
70
70
  "optionalDependencies": {
71
- "@drkmttr/adhere-darwin-arm64": "0.13.0",
72
- "@drkmttr/adhere-darwin-x64": "0.13.0",
73
- "@drkmttr/adhere-linux-arm64": "0.13.0",
74
- "@drkmttr/adhere-linux-x64": "0.13.0",
75
- "@drkmttr/adhere-win32-x64": "0.13.0"
71
+ "@drkmttr/adhere-darwin-arm64": "0.14.0",
72
+ "@drkmttr/adhere-darwin-x64": "0.14.0",
73
+ "@drkmttr/adhere-linux-arm64": "0.14.0",
74
+ "@drkmttr/adhere-linux-x64": "0.14.0",
75
+ "@drkmttr/adhere-win32-x64": "0.14.0"
76
76
  }
77
77
  }
@@ -53,7 +53,8 @@ config in `.adhere/` are not rules.
53
53
  description: A port must be a branded, range-checked integer, never a bare number.
54
54
  ---
55
55
 
56
- Prose here renders on GitHub and is ignored.
56
+ Prose here is the rule's details: Jev reads it after the description. Say why
57
+ the rule holds, or where it does not apply.
57
58
 
58
59
  ## Must
59
60
 
@@ -88,6 +89,9 @@ How to word a rule, from adhere's evals:
88
89
  - One pattern per rule. Two in one block blur the probability.
89
90
  - A heading names its code only when it is the word alone: `## Never`, not
90
91
  `## Never do this`. A fence tagged `ts never` names its code itself.
92
+ - Prose outside the code is sent to Jev with the description, so keep it about
93
+ the rule: why it holds, or where it does not apply. A note for the people who
94
+ maintain the rule, rather than for judging code, belongs elsewhere.
91
95
 
92
96
  Front matter besides `description`, all optional:
93
97
 
@@ -102,6 +106,15 @@ Front matter besides `description`, all optional:
102
106
  one line, scope a rule without widening its description: a finding stands
103
107
  only when Jev says the code that breaks the rule is as every `appliesTo`
104
108
  describes and as no `excludeIf` does.
109
+ - `reads: ["workspacePackages"]`, a JSON array on one line, names what Jev
110
+ reads for the rule beside the code, each under that name. There is one so
111
+ far, `workspacePackages`: each of the repository's own packages, by name,
112
+ with its directory, read from the root `package.json`'s `workspaces` or
113
+ `pnpm-workspace.yaml`. Use it for a rule that turns on whether an import is
114
+ of the repository's own package or an installed one, and name
115
+ `workspacePackages` in the description. A name adhere does not have refuses
116
+ the run. The rule costs a request of its own per file, and a changed
117
+ workspace judges it again.
105
118
 
106
119
  A rule's directory can hold a `RULE.ts` instead, which default-exports
107
120
  `defineRule({...})` from `@drkmttr/adhere`, with the fields a config's inline
@@ -38,8 +38,15 @@ wherever this skill says `adhere`, run it the manager's way, as in
38
38
  With pnpm, at a workspace root, add `--ignore-workspace-root-check`.
39
39
 
40
40
  In a repo without a `package.json`, use the `adhere` on PATH (`adhere
41
- --version`), or **Ask** before installing it globally with the manager the
42
- user has, such as `bun add --global @drkmttr/adhere`.
41
+ --version`), or **Ask** before installing the executable from the latest
42
+ release (`adhere-Windows-x86_64.exe` on Windows), which needs no Node or
43
+ package manager:
44
+
45
+ ```sh
46
+ curl -fsSL --create-dirs -o ~/.local/bin/adhere \
47
+ https://github.com/darkmatter/adhere/releases/latest/download/adhere-$(uname -s)-$(uname -m)
48
+ chmod +x ~/.local/bin/adhere
49
+ ```
43
50
 
44
51
  Validate and lint need a TypeSafe AI API key: `TYPESAFE_API_KEY`, or one saved
45
52
  by `adhere login`, which the user runs; never ask for the key in chat or print
@@ -205,9 +212,18 @@ jobs:
205
212
 
206
213
  Pin the versions the repo already uses: bun's `bun-version`, Node's
207
214
  `node-version` from `.nvmrc` or `engines`, and pnpm's from `packageManager`,
208
- which `pnpm/action-setup` reads. In a repo without a `package.json`, set up
209
- the manager the user has and run adhere at the version they ran locally, as
210
- in `bunx @drkmttr/adhere@0.11.0 lint --yes`.
215
+ which `pnpm/action-setup` reads. In a repo without a `package.json`, skip the
216
+ setup and install, and fetch the executable at the version the user ran
217
+ locally:
218
+
219
+ ```yaml
220
+ - run: |
221
+ curl -fsSL -o "$RUNNER_TEMP/adhere" https://github.com/darkmatter/adhere/releases/download/v0.14.0/adhere-$(uname -s)-$(uname -m)
222
+ chmod +x "$RUNNER_TEMP/adhere"
223
+ "$RUNNER_TEMP/adhere" lint --yes
224
+ env:
225
+ TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
226
+ ```
211
227
 
212
228
  Add `--deny-warnings` if the user wants warnings to fail the job too. Match
213
229
  the branch to the repo's default branch. For another CI system, write the
package/src/config.ts CHANGED
@@ -32,6 +32,15 @@ export type AppendState = (
32
32
  bun: BunApi,
33
33
  ) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>;
34
34
 
35
+ /**
36
+ * What a rule can have Jev read beside the code, each by the name of the key
37
+ * the state carries it under, which the rule's description can name in
38
+ * backticks. `workspacePackages` is the workspace's packages: each one's name
39
+ * with its directory, so a rule can tell an import of the repository's own
40
+ * package from an installed one's.
41
+ */
42
+ export const Reads = Schema.Array(Schema.Literals(["workspacePackages"]));
43
+
35
44
  /**
36
45
  * A rule's examples, named in RFC 2119's words, which Jev reads as written. A
37
46
  * requirement shows code that `must` be written and code that must `never`
@@ -40,6 +49,13 @@ export type AppendState = (
40
49
  */
41
50
  const RuleFields = Schema.Struct({
42
51
  description: Schema.String,
52
+ /**
53
+ * What the description leaves out, such as why the rule holds or where it
54
+ * does not: in a `RULE.md`, the prose around its code. Jev reads it after
55
+ * the description, as part of the rule; the report shows the description
56
+ * alone.
57
+ */
58
+ details: Schema.optionalKey(Schema.String),
43
59
  must: Schema.optionalKey(Schema.String),
44
60
  never: Schema.optionalKey(Schema.String),
45
61
  should: Schema.optionalKey(Schema.String),
@@ -67,6 +83,12 @@ const RuleFields = Schema.Struct({
67
83
  * that breaks the rule is as it describes, and a yes to any drops the finding.
68
84
  */
69
85
  excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
86
+ /**
87
+ * What Jev reads for the rule beside the code, by name (see `Reads`). The
88
+ * rule's questions go in requests of their own, so only they read it, and
89
+ * a change to what is read judges the rule again.
90
+ */
91
+ reads: Schema.optionalKey(Reads),
70
92
  /**
71
93
  * A hook on the state Jev reads for the rule, in a rule written in
72
94
  * TypeScript (see `AppendState`). The rule's questions go in requests of
@@ -296,6 +318,12 @@ export interface ResolvedConfig {
296
318
  readonly exclude?: ReadonlyArray<string>;
297
319
  /** Whether Jev reads every comment; unset, only the `@adhere` notes. */
298
320
  readonly includeComments?: boolean;
321
+ /**
322
+ * The workspace's packages, each name with its directory from the working
323
+ * directory, read from its manifests on every run, when a rule's `reads`
324
+ * names them.
325
+ */
326
+ readonly workspacePackages?: Readonly<Record<string, string>>;
299
327
  }
300
328
 
301
329
  /** Command-line values that apply on top of the config file. */
package/src/init.ts CHANGED
@@ -68,9 +68,8 @@ description: A file should be small and focused, with one primary responsibility
68
68
  threshold: 0.8
69
69
  ---
70
70
 
71
- A guideline, so its description and its headings say should and should not. A
72
- rule that must hold says must and never instead, as name-domain-actions does.
73
- Use this rule as a template for repository-specific conventions.
71
+ Why: a file that does one thing is easier to find, read, and change. A helper
72
+ that serves only that thing belongs in the same file.
74
73
 
75
74
  ## Should
76
75
 
@@ -93,6 +92,9 @@ description: A function must be named after the domain action it performs, never
93
92
  threshold: 0.8
94
93
  ---
95
94
 
95
+ Why: a name like \`handle\` or \`process\` says nothing about what the function
96
+ does, so every caller has to open it to find out.
97
+
96
98
  ## Must
97
99
 
98
100
  \`\`\`ts
@@ -104,9 +106,6 @@ export const loadCustomerProfile = (customerId: CustomerId) =>
104
106
 
105
107
  ## Never
106
108
 
107
- The code under Never shows what a violation looks like. It is optional, and a
108
- rule can have it without the code under Must.
109
-
110
109
  \`\`\`ts
111
110
  export const handle = (id: string) =>
112
111
  Effect.gen(function* () {
package/src/markdown.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ConfigUnavailable, type Example, type Rule } from "#config.ts";
1
+ import { ConfigUnavailable, type Example, Reads, type Rule } from "#config.ts";
2
2
  import { Effect, Schema } from "effect";
3
3
 
4
4
  /**
@@ -8,7 +8,7 @@ import { Effect, Schema } from "effect";
8
8
  * description: One sentence saying what code must be, or never be.
9
9
  * threshold: 0.8
10
10
  * ---
11
- * Any prose, rendered on GitHub and ignored by adhere.
11
+ * Any prose, which Jev reads after the description, as the rule's details.
12
12
  *
13
13
  * ## Must
14
14
  *
@@ -34,6 +34,8 @@ import { Effect, Schema } from "effect";
34
34
  * `warning` reports the rule's findings as warnings, which do not fail a run.
35
35
  * `appliesTo` and `excludeIf`, the rule's scope, are JSON arrays of strings on
36
36
  * one line, as in `excludeIf: ["a type that mirrors a third-party format"]`.
37
+ * So is `reads`, of the names of what Jev reads for the rule beside the code,
38
+ * as in `reads: ["workspacePackages"]`.
37
39
  */
38
40
  const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
39
41
 
@@ -44,6 +46,7 @@ const FrontMatter = Schema.Struct({
44
46
  level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
45
47
  appliesTo: Schema.optionalKey(Matchers),
46
48
  excludeIf: Schema.optionalKey(Matchers),
49
+ reads: Schema.optionalKey(Schema.fromJsonString(Reads)),
47
50
  });
48
51
 
49
52
  const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
@@ -148,6 +151,24 @@ const fencesOf = (body: string): ReadonlyArray<Fence> => {
148
151
  const nonEmpty = (code: string | undefined): string | undefined =>
149
152
  code === undefined || code.length === 0 ? undefined : code;
150
153
 
154
+ /**
155
+ * The body's prose: what is left outside its fences and the headings that
156
+ * name their code, which Jev reads after the description. A body without a
157
+ * fence is all code, so it has none.
158
+ */
159
+ const detailsOf = (body: string): string | undefined =>
160
+ fencesOf(body).length === 0
161
+ ? undefined
162
+ : nonEmpty(
163
+ body
164
+ .replace(FENCE, "")
165
+ .replace(HEADING, (line, _level, text: string | undefined) =>
166
+ headingWordOf(text ?? "") === undefined ? line : "",
167
+ )
168
+ .replace(/\n{3,}/g, "\n\n")
169
+ .trim(),
170
+ );
171
+
151
172
  type Code = Pick<Rule, "must" | "never" | "should" | "shouldNot">;
152
173
 
153
174
  /**
@@ -206,5 +227,6 @@ export const parseRuleMarkdown = (
206
227
  message: `${file}: the body needs code, in a fence under a Must, Never, Should, or Should not heading, or untagged`,
207
228
  });
208
229
  }
209
- return { ...front, ...code };
230
+ const details = detailsOf(match[2]);
231
+ return { ...front, ...(details === undefined ? {} : { details }), ...code };
210
232
  });
@@ -21,6 +21,7 @@ import {
21
21
  shownId,
22
22
  withOverrides,
23
23
  } from "#rules.ts";
24
+ import { workspacePackagesOf } from "#workspaces.ts";
24
25
  import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
25
26
 
26
27
  export class AdhereConfig extends Context.Service<AdhereConfig, ResolvedConfig>()(
@@ -129,6 +130,17 @@ export const AdhereConfigLive = (flags: Flags) =>
129
130
  });
130
131
  }
131
132
  }
132
- return AdhereConfig.of({ ...resolved, scopedRules: withOverrides(entries, overrides) });
133
+ const scopedRules = withOverrides(entries, overrides);
134
+ // The manifests are read on every run, so a changed workspace is seen, and only when a rule reads them.
135
+ const workspacePackages = scopedRules.some(
136
+ (entry) => entry.rule.reads?.includes("workspacePackages") === true,
137
+ )
138
+ ? yield* workspacePackagesOf(cwd)
139
+ : undefined;
140
+ return AdhereConfig.of({
141
+ ...resolved,
142
+ scopedRules,
143
+ ...(workspacePackages === undefined ? {} : { workspacePackages }),
144
+ });
133
145
  }),
134
146
  );
@@ -68,14 +68,21 @@ const refused = (message: string) => JevUnavailable.make({ message });
68
68
  const bun = (globalThis as { readonly Bun?: unknown }).Bun as Parameters<AppendState>[2];
69
69
 
70
70
  /**
71
- * A body as it goes out: when its rule has `appendState`, with what the hook
72
- * returns spread over the body's state. A hook that fails refuses the run.
71
+ * A body as it goes out: when its rule's `reads` names the workspace's
72
+ * packages, with them in its state, and when it has `appendState`, with what
73
+ * the hook returns spread over that. A hook that fails refuses the run.
73
74
  */
74
75
  const hooked = (
75
- body: Body & { readonly state: JevState },
76
+ sent: Body & { readonly state: JevState },
76
77
  group: Rules,
77
78
  file: JudgedFile,
79
+ workspacePackages: Readonly<Record<string, string>>,
78
80
  ): Effect.Effect<Body, JevUnavailable> => {
81
+ const body = Object.values(group).some(
82
+ (rule) => rule.reads?.includes("workspacePackages") === true,
83
+ )
84
+ ? { ...sent, state: { ...sent.state, workspacePackages } }
85
+ : sent;
79
86
  const [hook] = Object.entries(group).flatMap(([id, rule]) =>
80
87
  rule.appendState === undefined ? [] : [[id, rule.appendState] as const],
81
88
  );
@@ -273,8 +280,9 @@ export const JevLive = Layer.effect(Jev)(
273
280
  ) =>
274
281
  Effect.map(
275
282
  Effect.forEach(requestGroups(rules), (group) =>
276
- Effect.flatMap(hooked(bodyOf(group), group, file), (body) =>
277
- answersTo(kind, body, Answers),
283
+ Effect.flatMap(
284
+ hooked(bodyOf(group), group, file, config.workspacePackages ?? {}),
285
+ (body) => answersTo(kind, body, Answers),
278
286
  ),
279
287
  ),
280
288
  (groups) =>
@@ -160,11 +160,15 @@ const exampleFields = (rule: Rule): Record<string, string> => {
160
160
  };
161
161
  };
162
162
 
163
- /** The rule as its questions carry it: the description, and its examples under their words. */
164
- const ruleFields = (rule: Rule) => ({ rule: rule.description, ...exampleFields(rule) });
163
+ /** The rule in words, as Jev reads it: its description, then its details when it has any. */
164
+ const ruleText = (rule: Rule): string =>
165
+ rule.details === undefined ? rule.description : `${rule.description}\n\n${rule.details}`;
166
+
167
+ /** The rule as its questions carry it: its text, and its examples under their words. */
168
+ const ruleFields = (rule: Rule) => ({ rule: ruleText(rule), ...exampleFields(rule) });
165
169
 
166
170
  /** A rule's text as a comparison of rules reads it. */
167
- const ruleState = (rule: Rule) => ({ description: rule.description, ...exampleFields(rule) });
171
+ const ruleState = (rule: Rule) => ({ description: ruleText(rule), ...exampleFields(rule) });
168
172
 
169
173
  /**
170
174
  * The compared rules a question mentions, keyed by index: ids alone would
@@ -477,18 +481,20 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
477
481
  return requests.map(({ questions }) => ({ ...body, questions }));
478
482
  };
479
483
 
484
+ /** Whether a rule reads state of its own: what its `appendState` adds, or what its `reads` names. */
485
+ const readsOwnState = (rule: Rule): boolean =>
486
+ rule.appendState !== undefined || (rule.reads ?? []).length > 0;
487
+
480
488
  /**
481
- * The rules as requests carry them: every rule without `appendState` in one
482
- * body, and each rule with it in a body of its own, so that what its hook
483
- * puts in the state only its own questions read.
489
+ * The rules as requests carry them: every rule that reads only the code in
490
+ * one body, and each rule with state of its own in a body of its own, so
491
+ * that what is added to the state for it only its own questions read.
484
492
  */
485
493
  export const requestGroups = (rules: Rules): ReadonlyArray<Rules> => {
486
- const shared = Record.filter(rules, (rule) => rule.appendState === undefined);
494
+ const shared = Record.filter(rules, (rule) => !readsOwnState(rule));
487
495
  return [
488
496
  ...(Record.isEmptyRecord(shared) ? [] : [shared]),
489
- ...Object.entries(rules).flatMap(([id, rule]) =>
490
- rule.appendState === undefined ? [] : [{ [id]: rule }],
491
- ),
497
+ ...Object.entries(rules).flatMap(([id, rule]) => (readsOwnState(rule) ? [{ [id]: rule }] : [])),
492
498
  ];
493
499
  };
494
500
 
@@ -112,14 +112,24 @@ const LAYOUT = "sections";
112
112
  * judge question, and its matchers' when it has any. An edited rule or
113
113
  * matcher re-judges the rule, and a question asked differently re-judges
114
114
  * every rule. So does an edited `appendState`, by its source; what the hook
115
- * reads outside the file is not known until it runs, so a change there does not.
115
+ * reads outside the file is not known until it runs, so a change there does
116
+ * not. A rule that reads the workspace's packages depends on them too, so a
117
+ * package added, renamed, or moved re-judges that rule and no other.
116
118
  */
117
- const fingerprintOf = (model: string, rule: Rule) => {
119
+ const fingerprintOf = (
120
+ model: string,
121
+ rule: Rule,
122
+ workspacePackages: Readonly<Record<string, string>> = {},
123
+ ) => {
118
124
  const matchers = matcherQuestions("", rule).map(([, question]) => question);
119
125
  const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
120
126
  const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
127
+ const packages =
128
+ rule.reads?.includes("workspacePackages") === true
129
+ ? `\u0000${JSON.stringify(workspacePackages)}`
130
+ : "";
121
131
  return sha256(
122
- `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
132
+ `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${packages}`,
123
133
  );
124
134
  };
125
135
 
@@ -314,7 +324,7 @@ export const planAudit = (
314
324
  const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
315
325
  Object.entries(rules),
316
326
  ([id, rule]) =>
317
- Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
327
+ Effect.map(fingerprintOf(config.model, rule, config.workspacePackages), (fingerprint) => {
318
328
  const preset = presetOfRule.get(rule);
319
329
  return [
320
330
  id,
@@ -0,0 +1,141 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+
3
+ /** A workspace's packages: each one's name, with its directory from the root. */
4
+ export type WorkspacePackages = Readonly<Record<string, string>>;
5
+
6
+ /** What JSON text holds, or nothing when it is not JSON. */
7
+ const parsed = (text: string): unknown => {
8
+ try {
9
+ return JSON.parse(text);
10
+ } catch {
11
+ return undefined;
12
+ }
13
+ };
14
+
15
+ /** The strings of a value that is an array of them, and none of any other value. */
16
+ const strings = (value: unknown): ReadonlyArray<string> =>
17
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
18
+
19
+ /**
20
+ * A root package.json's workspace patterns: `workspaces` as an array, which
21
+ * npm, Bun, and Yarn read, or as Yarn's `{ packages: [...] }`.
22
+ */
23
+ const manifestPatterns = (manifest: unknown): ReadonlyArray<string> => {
24
+ const workspaces = (manifest as { readonly workspaces?: unknown } | undefined)?.workspaces;
25
+ return Array.isArray(workspaces)
26
+ ? strings(workspaces)
27
+ : strings((workspaces as { readonly packages?: unknown } | undefined)?.packages);
28
+ };
29
+
30
+ /**
31
+ * The patterns under `packages:` in a pnpm-workspace.yaml: the list items
32
+ * that follow it, up to the next key. Read line by line, since that list is
33
+ * all of the file adhere needs and the executable carries no YAML parser.
34
+ */
35
+ const pnpmPatterns = (yaml: string): ReadonlyArray<string> => {
36
+ const lines = yaml.split(/\r?\n/);
37
+ const start = lines.findIndex((line) => /^packages:\s*$/.test(line));
38
+ if (start < 0) return [];
39
+ const rest = lines.slice(start + 1);
40
+ const end = rest.findIndex((line) => /^\S/.test(line) && !line.startsWith("-"));
41
+ return (end < 0 ? rest : rest.slice(0, end)).flatMap((line) => {
42
+ const [, item] = /^\s*-\s*(.+?)\s*$/.exec(line) ?? [];
43
+ return item === undefined ? [] : [item.replace(/^["']|["']$/g, "")];
44
+ });
45
+ };
46
+
47
+ /** Whether a directory's name matches a pattern segment, where `*` stands for any run of characters. */
48
+ const matches = (segment: string, name: string): boolean =>
49
+ new RegExp(
50
+ `^${segment
51
+ .split("*")
52
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
53
+ .join(".*")}$`,
54
+ ).test(name);
55
+
56
+ /**
57
+ * The packages of the workspace whose root is `root`, by name, sorted, so
58
+ * the same workspace always reads the same: from the root package.json's
59
+ * `workspaces` and from pnpm-workspace.yaml's `packages`. A pattern's `*` is
60
+ * any directory and `**` any depth of them, outside `node_modules` and dot
61
+ * directories, and one that starts with `!` leaves out what it matches. A
62
+ * root that has neither file, or names no workspaces, has no packages.
63
+ */
64
+ export const workspacePackagesOf = Effect.fn("workspacePackagesOf")(function* (root: string) {
65
+ const fs = yield* FileSystem.FileSystem;
66
+ const path = yield* Path.Path;
67
+ const read = (file: string) =>
68
+ fs.readFileString(path.join(root, file)).pipe(Effect.orElseSucceed(() => ""));
69
+ const children = (directory: string) =>
70
+ fs.readDirectory(path.join(root, directory)).pipe(
71
+ Effect.orElseSucceed((): ReadonlyArray<string> => []),
72
+ Effect.map((names) =>
73
+ names.filter((name) => name !== "node_modules" && !name.startsWith(".")),
74
+ ),
75
+ Effect.flatMap((names) =>
76
+ Effect.filter(names, (name) =>
77
+ fs.stat(path.join(root, directory, name)).pipe(
78
+ Effect.map((info) => info.type === "Directory"),
79
+ Effect.orElseSucceed(() => false),
80
+ ),
81
+ ),
82
+ ),
83
+ Effect.map((names) =>
84
+ names.map((name) => (directory === "" ? name : `${directory}/${name}`)),
85
+ ),
86
+ );
87
+ /** The directories under `directory` that the pattern's remaining segments lead to. */
88
+ const expand = (
89
+ directory: string,
90
+ segments: ReadonlyArray<string>,
91
+ ): Effect.Effect<ReadonlyArray<string>> => {
92
+ const [segment, ...rest] = segments;
93
+ if (segment === undefined) return Effect.succeed([directory]);
94
+ if (segment === "**") {
95
+ return Effect.gen(function* () {
96
+ const below = yield* Effect.forEach(yield* children(directory), (child) =>
97
+ expand(child, segments),
98
+ );
99
+ return [...(yield* expand(directory, rest)), ...below.flat()];
100
+ });
101
+ }
102
+ if (!segment.includes("*")) {
103
+ return expand(directory === "" ? segment : `${directory}/${segment}`, rest);
104
+ }
105
+ return Effect.flatMap(children(directory), (names) =>
106
+ Effect.map(
107
+ Effect.forEach(
108
+ names.filter((name) => matches(segment, name.slice(name.lastIndexOf("/") + 1))),
109
+ (child) => expand(child, rest),
110
+ ),
111
+ (found) => found.flat(),
112
+ ),
113
+ );
114
+ };
115
+ const segmentsOf = (pattern: string) =>
116
+ pattern.split("/").filter((segment) => segment !== "" && segment !== ".");
117
+ const patterns = [
118
+ ...manifestPatterns(parsed(yield* read("package.json"))),
119
+ ...pnpmPatterns(yield* read("pnpm-workspace.yaml")),
120
+ ];
121
+ const directoriesOf = (chosen: ReadonlyArray<string>) =>
122
+ Effect.map(
123
+ Effect.forEach(chosen, (pattern) => expand("", segmentsOf(pattern))),
124
+ (found) => new Set(found.flat()),
125
+ );
126
+ const included = yield* directoriesOf(patterns.filter((pattern) => !pattern.startsWith("!")));
127
+ const excluded = yield* directoriesOf(
128
+ patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1)),
129
+ );
130
+ const named = yield* Effect.forEach(
131
+ [...included].filter((directory) => !excluded.has(directory)),
132
+ (directory) =>
133
+ Effect.map(read(`${directory}/package.json`), (text) => {
134
+ const name = (parsed(text) as { readonly name?: unknown } | undefined)?.name;
135
+ return typeof name === "string" ? [[name, directory] as const] : [];
136
+ }),
137
+ );
138
+ return Object.fromEntries(
139
+ named.flat().sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
140
+ ) as WorkspacePackages;
141
+ });