@drkmttr/adhere 0.13.0 → 0.13.1

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/README.md CHANGED
@@ -676,6 +676,51 @@ its matchers dropped, with their scores.
676
676
  Keeping the scope out of the description keeps the description to what the
677
677
  rule asks for, which the report shows, and gives each part a score of its own.
678
678
 
679
+ ### Workspace packages
680
+
681
+ A rule can turn on whose package an import is of: the repository's own, or
682
+ one installed from a registry. The file alone does not show that, so a rule
683
+ can ask for the workspace's packages, with `includeWorkspacePackages: true`
684
+ in its front matter, or as a field of a rule in TypeScript or a config:
685
+
686
+ ````md
687
+ ---
688
+ description: A function from one of this repository's own packages, which `workspacePackages` names, must be called through the package's namespace, never imported by its own name.
689
+ includeWorkspacePackages: true
690
+ ---
691
+
692
+ ## Must
693
+
694
+ ```ts
695
+ import * as Orders from "orders-core";
696
+ ```
697
+
698
+ ## Never
699
+
700
+ ```ts
701
+ import { parse } from "orders-core";
702
+ ```
703
+ ````
704
+
705
+ Jev then reads, beside the code, `workspacePackages`: each package's name
706
+ with its directory from the working directory, as in
707
+ `{ "orders-core": "packages/orders" }`. The description can name that key in
708
+ backticks, as the questions name `code`. A package that is not in it is an
709
+ installed one.
710
+
711
+ The packages are read on every run, when a rule asks for them, from the
712
+ working directory's `package.json`, whose `workspaces` npm, Bun, and Yarn
713
+ read, and from `pnpm-workspace.yaml`'s `packages`. In a pattern, `*` is any
714
+ directory and `**` any depth of them, and one that starts with `!` leaves out
715
+ what it matches. Each matched directory's `package.json` gives the name.
716
+ Nothing need be installed, so a run in CI reads the same packages as one on a
717
+ laptop. A working directory that is not a workspace's root has none.
718
+
719
+ Such a rule goes to Jev in requests of its own, as a rule with `appendState`
720
+ does, so no other rule reads the packages. They are part of what its
721
+ judgments are cached under: when a package is added, renamed, or moved, that
722
+ rule is judged again for every file, and no other rule is.
723
+
679
724
  ### Rule writing tips
680
725
 
681
726
  We've evaluated different ways of giving Jev a rule, to catch the most
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
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.13.1",
72
+ "@drkmttr/adhere-darwin-x64": "0.13.1",
73
+ "@drkmttr/adhere-linux-arm64": "0.13.1",
74
+ "@drkmttr/adhere-linux-x64": "0.13.1",
75
+ "@drkmttr/adhere-win32-x64": "0.13.1"
76
76
  }
77
77
  }
@@ -102,6 +102,13 @@ Front matter besides `description`, all optional:
102
102
  one line, scope a rule without widening its description: a finding stands
103
103
  only when Jev says the code that breaks the rule is as every `appliesTo`
104
104
  describes and as no `excludeIf` does.
105
+ - `includeWorkspacePackages: true` gives Jev `workspacePackages` beside the
106
+ code: each of the repository's own packages, by name, with its directory,
107
+ read from the root `package.json`'s `workspaces` or `pnpm-workspace.yaml`.
108
+ Use it for a rule that turns on whether an import is of the repository's
109
+ own package or an installed one, and name `workspacePackages` in the
110
+ description. The rule costs a request of its own per file, and a changed
111
+ workspace judges it again.
105
112
 
106
113
  A rule's directory can hold a `RULE.ts` instead, which default-exports
107
114
  `defineRule({...})` from `@drkmttr/adhere`, with the fields a config's inline
package/src/config.ts CHANGED
@@ -67,6 +67,14 @@ const RuleFields = Schema.Struct({
67
67
  * that breaks the rule is as it describes, and a yes to any drops the finding.
68
68
  */
69
69
  excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
70
+ /**
71
+ * Whether Jev reads, beside the code, the workspace's packages as
72
+ * `workspacePackages`: each one's name with its directory, so the rule can
73
+ * tell an import of the repository's own package from an installed one's.
74
+ * The rule's questions go in requests of their own, and a change to the
75
+ * packages judges it again.
76
+ */
77
+ includeWorkspacePackages: Schema.optionalKey(Schema.Boolean),
70
78
  /**
71
79
  * A hook on the state Jev reads for the rule, in a rule written in
72
80
  * TypeScript (see `AppendState`). The rule's questions go in requests of
@@ -296,6 +304,12 @@ export interface ResolvedConfig {
296
304
  readonly exclude?: ReadonlyArray<string>;
297
305
  /** Whether Jev reads every comment; unset, only the `@adhere` notes. */
298
306
  readonly includeComments?: boolean;
307
+ /**
308
+ * The workspace's packages, each name with its directory from the working
309
+ * directory, read from its manifests on every run, when a rule asks for
310
+ * them with `includeWorkspacePackages`.
311
+ */
312
+ readonly workspacePackages?: Readonly<Record<string, string>>;
299
313
  }
300
314
 
301
315
  /** Command-line values that apply on top of the config file. */
package/src/markdown.ts CHANGED
@@ -44,6 +44,7 @@ const FrontMatter = Schema.Struct({
44
44
  level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
45
45
  appliesTo: Schema.optionalKey(Matchers),
46
46
  excludeIf: Schema.optionalKey(Matchers),
47
+ includeWorkspacePackages: Schema.optionalKey(Schema.Literals(["true", "false"])),
47
48
  });
48
49
 
49
50
  const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
@@ -206,5 +207,12 @@ export const parseRuleMarkdown = (
206
207
  message: `${file}: the body needs code, in a fence under a Must, Never, Should, or Should not heading, or untagged`,
207
208
  });
208
209
  }
209
- return { ...front, ...code };
210
+ const { includeWorkspacePackages, ...fields } = front;
211
+ return {
212
+ ...fields,
213
+ ...(includeWorkspacePackages === undefined
214
+ ? {}
215
+ : { includeWorkspacePackages: includeWorkspacePackages === "true" }),
216
+ ...code,
217
+ };
210
218
  });
@@ -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.includeWorkspacePackages === 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,19 @@ 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 has `includeWorkspacePackages`, with
72
+ * the workspace's packages in its state, and when it has `appendState`, with
73
+ * what 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((rule) => rule.includeWorkspacePackages === true)
82
+ ? { ...sent, state: { ...sent.state, workspacePackages } }
83
+ : sent;
79
84
  const [hook] = Object.entries(group).flatMap(([id, rule]) =>
80
85
  rule.appendState === undefined ? [] : [[id, rule.appendState] as const],
81
86
  );
@@ -273,8 +278,9 @@ export const JevLive = Layer.effect(Jev)(
273
278
  ) =>
274
279
  Effect.map(
275
280
  Effect.forEach(requestGroups(rules), (group) =>
276
- Effect.flatMap(hooked(bodyOf(group), group, file), (body) =>
277
- answersTo(kind, body, Answers),
281
+ Effect.flatMap(
282
+ hooked(bodyOf(group), group, file, config.workspacePackages ?? {}),
283
+ (body) => answersTo(kind, body, Answers),
278
284
  ),
279
285
  ),
280
286
  (groups) =>
@@ -477,18 +477,20 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
477
477
  return requests.map(({ questions }) => ({ ...body, questions }));
478
478
  };
479
479
 
480
+ /** Whether a rule reads state of its own: what its `appendState` adds, or the workspace's packages. */
481
+ const readsOwnState = (rule: Rule): boolean =>
482
+ rule.appendState !== undefined || rule.includeWorkspacePackages === true;
483
+
480
484
  /**
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.
485
+ * The rules as requests carry them: every rule that reads only the code in
486
+ * one body, and each rule with state of its own in a body of its own, so
487
+ * that what is added to the state for it only its own questions read.
484
488
  */
485
489
  export const requestGroups = (rules: Rules): ReadonlyArray<Rules> => {
486
- const shared = Record.filter(rules, (rule) => rule.appendState === undefined);
490
+ const shared = Record.filter(rules, (rule) => !readsOwnState(rule));
487
491
  return [
488
492
  ...(Record.isEmptyRecord(shared) ? [] : [shared]),
489
- ...Object.entries(rules).flatMap(([id, rule]) =>
490
- rule.appendState === undefined ? [] : [{ [id]: rule }],
491
- ),
493
+ ...Object.entries(rules).flatMap(([id, rule]) => (readsOwnState(rule) ? [{ [id]: rule }] : [])),
492
494
  ];
493
495
  };
494
496
 
@@ -112,14 +112,22 @@ 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.includeWorkspacePackages === true ? `\u0000${JSON.stringify(workspacePackages)}` : "";
121
129
  return sha256(
122
- `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
130
+ `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${packages}`,
123
131
  );
124
132
  };
125
133
 
@@ -314,7 +322,7 @@ export const planAudit = (
314
322
  const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
315
323
  Object.entries(rules),
316
324
  ([id, rule]) =>
317
- Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
325
+ Effect.map(fingerprintOf(config.model, rule, config.workspacePackages), (fingerprint) => {
318
326
  const preset = presetOfRule.get(rule);
319
327
  return [
320
328
  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
+ });