@drkmttr/adhere 0.12.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.
Files changed (104) hide show
  1. package/README.md +194 -51
  2. package/package.json +9 -9
  3. package/presets/effect/basics/{instrument-with-pipe.md → instrument-with-pipe/RULE.md} +1 -1
  4. package/presets/effect/config/{business-logic-depends-on-config-service.md → business-logic-depends-on-config-service/RULE.md} +3 -3
  5. package/presets/effect/config/{secrets-are-redacted.md → secrets-are-redacted/RULE.md} +3 -3
  6. package/presets/effect/config/{validate-with-schema.md → validate-with-schema/RULE.md} +4 -4
  7. package/skills/adhere/SKILL.md +213 -118
  8. package/skills/adhere-fix/SKILL.md +9 -8
  9. package/skills/adhere-setup/SKILL.md +244 -0
  10. package/src/cli.ts +49 -33
  11. package/src/config.ts +64 -3
  12. package/src/index.ts +2 -0
  13. package/src/init.ts +36 -25
  14. package/src/install.ts +37 -26
  15. package/src/main.ts +1 -1
  16. package/src/markdown.ts +9 -1
  17. package/src/rules.ts +135 -56
  18. package/src/services/AdhereConfig.ts +15 -3
  19. package/src/services/Credentials.ts +4 -4
  20. package/src/services/Jev.http.ts +82 -10
  21. package/src/services/Jev.ts +41 -10
  22. package/src/workflows/audit.ts +33 -14
  23. package/src/workflows/format.ts +172 -35
  24. package/src/workspaces.ts +141 -0
  25. /package/presets/alchemy/apis/{match-remote-errors-by-tag.md → match-remote-errors-by-tag/RULE.md} +0 -0
  26. /package/presets/alchemy/apis/{schemas-at-trust-boundaries.md → schemas-at-trust-boundaries/RULE.md} +0 -0
  27. /package/presets/alchemy/data/{commit-migrations-then-deploy.md → commit-migrations-then-deploy/RULE.md} +0 -0
  28. /package/presets/alchemy/data/{hyperdrive-direct-origin.md → hyperdrive-direct-origin/RULE.md} +0 -0
  29. /package/presets/alchemy/data/{migrations-use-direct-url.md → migrations-use-direct-url/RULE.md} +0 -0
  30. /package/presets/alchemy/data/{never-disable-tls-verification.md → never-disable-tls-verification/RULE.md} +0 -0
  31. /package/presets/alchemy/data/{private-endpoint-at-runtime.md → private-endpoint-at-runtime/RULE.md} +0 -0
  32. /package/presets/alchemy/data/{single-migration-owner.md → single-migration-owner/RULE.md} +0 -0
  33. /package/presets/alchemy/durable/{idempotent-event-handlers.md → idempotent-event-handlers/RULE.md} +0 -0
  34. /package/presets/alchemy/durable/{io-inside-steps.md → io-inside-steps/RULE.md} +0 -0
  35. /package/presets/alchemy/durable/{no-self-triggering-bucket-writes.md → no-self-triggering-bucket-writes/RULE.md} +0 -0
  36. /package/presets/alchemy/durable/{stable-step-names.md → stable-step-names/RULE.md} +0 -0
  37. /package/presets/alchemy/durable/{webhook-state-is-truth.md → webhook-state-is-truth/RULE.md} +0 -0
  38. /package/presets/alchemy/providers/{diff-guards-unresolved-props.md → diff-guards-unresolved-props/RULE.md} +0 -0
  39. /package/presets/alchemy/providers/{idempotent-delete.md → idempotent-delete/RULE.md} +0 -0
  40. /package/presets/alchemy/providers/{lazy-layer-construction.md → lazy-layer-construction/RULE.md} +0 -0
  41. /package/presets/alchemy/providers/{reconcile-from-observed-state.md → reconcile-from-observed-state/RULE.md} +0 -0
  42. /package/presets/alchemy/resources/{anchor-paths-with-import-meta.md → anchor-paths-with-import-meta/RULE.md} +0 -0
  43. /package/presets/alchemy/resources/{idempotent-actions-and-seeds.md → idempotent-actions-and-seeds/RULE.md} +0 -0
  44. /package/presets/alchemy/resources/{previews-reference-shared-resources.md → previews-reference-shared-resources/RULE.md} +0 -0
  45. /package/presets/alchemy/resources/{protect-irreplaceable-data.md → protect-irreplaceable-data/RULE.md} +0 -0
  46. /package/presets/alchemy/resources/{remote-state-for-ci.md → remote-state-for-ci/RULE.md} +0 -0
  47. /package/presets/alchemy/resources/{stable-logical-ids.md → stable-logical-ids/RULE.md} +0 -0
  48. /package/presets/alchemy/resources/{stage-unique-physical-names.md → stage-unique-physical-names/RULE.md} +0 -0
  49. /package/presets/alchemy/runtime/{bind-tag-not-implementation.md → bind-tag-not-implementation/RULE.md} +0 -0
  50. /package/presets/alchemy/runtime/{deterministic-config-defaults.md → deterministic-config-defaults/RULE.md} +0 -0
  51. /package/presets/alchemy/runtime/{disposables-in-request-scope.md → disposables-in-request-scope/RULE.md} +0 -0
  52. /package/presets/alchemy/runtime/{lambda-no-post-response-work.md → lambda-no-post-response-work/RULE.md} +0 -0
  53. /package/presets/alchemy/runtime/{no-per-request-work-at-construction.md → no-per-request-work-at-construction/RULE.md} +0 -0
  54. /package/presets/alchemy/runtime/{resolve-config-and-bindings-at-construction.md → resolve-config-and-bindings-at-construction/RULE.md} +0 -0
  55. /package/presets/alchemy/secrets/{let-alchemy-mint-infra-secrets.md → let-alchemy-mint-infra-secrets/RULE.md} +0 -0
  56. /package/presets/alchemy/secrets/{none-in-browser-env.md → none-in-browser-env/RULE.md} +0 -0
  57. /package/presets/alchemy/secrets/{redacted-until-use.md → redacted-until-use/RULE.md} +0 -0
  58. /package/presets/alchemy/secrets/{unwrap-before-compare-or-send.md → unwrap-before-compare-or-send/RULE.md} +0 -0
  59. /package/presets/alchemy/security/{authorize-public-endpoints.md → authorize-public-endpoints/RULE.md} +0 -0
  60. /package/presets/alchemy/security/{github-secret-not-variable.md → github-secret-not-variable/RULE.md} +0 -0
  61. /package/presets/alchemy/security/{identity-from-verified-session.md → identity-from-verified-session/RULE.md} +0 -0
  62. /package/presets/alchemy/security/{keep-auth-protections-on.md → keep-auth-protections-on/RULE.md} +0 -0
  63. /package/presets/alchemy/security/{least-privilege-bindings.md → least-privilege-bindings/RULE.md} +0 -0
  64. /package/presets/alchemy/security/{least-privilege-ci-credentials.md → least-privilege-ci-credentials/RULE.md} +0 -0
  65. /package/presets/alchemy/security/{lookup-failure-is-not-anonymous.md → lookup-failure-is-not-anonymous/RULE.md} +0 -0
  66. /package/presets/effect/basics/{gen-for-sequencing.md → gen-for-sequencing/RULE.md} +0 -0
  67. /package/presets/effect/config/{tests-provide-values-directly.md → tests-provide-values-directly/RULE.md} +0 -0
  68. /package/presets/effect/data/{brand-meaningful-primitives.md → brand-meaningful-primitives/RULE.md} +0 -0
  69. /package/presets/effect/data/{variants-are-tagged-unions.md → variants-are-tagged-unions/RULE.md} +0 -0
  70. /package/presets/effect/errors/{catch-defects-at-boundaries-only.md → catch-defects-at-boundaries-only/RULE.md} +0 -0
  71. /package/presets/effect/errors/{defects-for-bugs.md → defects-for-bugs/RULE.md} +0 -0
  72. /package/presets/effect/errors/{wrap-external-errors.md → wrap-external-errors/RULE.md} +0 -0
  73. /package/presets/effect/services/{dependencies-through-layers.md → dependencies-through-layers/RULE.md} +0 -0
  74. /package/presets/effect/services/{fresh-layer-per-test.md → fresh-layer-per-test/RULE.md} +0 -0
  75. /package/presets/effect/services/{memoize-parameterized-layers.md → memoize-parameterized-layers/RULE.md} +0 -0
  76. /package/presets/effect/services/{no-mutable-state.md → no-mutable-state/RULE.md} +0 -0
  77. /package/presets/effect/services/{operations-have-no-requirements.md → operations-have-no-requirements/RULE.md} +0 -0
  78. /package/presets/effect/services/{test-layers-are-in-memory.md → test-layers-are-in-memory/RULE.md} +0 -0
  79. /package/presets/effect/testing/{test-clock-for-time.md → test-clock-for-time/RULE.md} +0 -0
  80. /package/presets/react/effects/{clean-up-what-they-start.md → clean-up-what-they-start/RULE.md} +0 -0
  81. /package/presets/react/effects/{event-logic-in-handlers.md → event-logic-in-handlers/RULE.md} +0 -0
  82. /package/presets/react/effects/{external-stores-use-sync-external-store.md → external-stores-use-sync-external-store/RULE.md} +0 -0
  83. /package/presets/react/effects/{fetches-ignore-stale-responses.md → fetches-ignore-stale-responses/RULE.md} +0 -0
  84. /package/presets/react/effects/{notify-parents-in-handlers.md → notify-parents-in-handlers/RULE.md} +0 -0
  85. /package/presets/react/server/{no-private-data-to-client.md → no-private-data-to-client/RULE.md} +0 -0
  86. /package/presets/react/server/{server-functions-authorize-and-validate.md → server-functions-authorize-and-validate/RULE.md} +0 -0
  87. /package/presets/react/state/{no-contradictory-state.md → no-contradictory-state/RULE.md} +0 -0
  88. /package/presets/react/state/{no-props-copied-into-state.md → no-props-copied-into-state/RULE.md} +0 -0
  89. /package/presets/react/state/{store-ids-not-copies.md → store-ids-not-copies/RULE.md} +0 -0
  90. /package/presets/security/{constant-time-secret-compare.md → constant-time-secret-compare/RULE.md} +0 -0
  91. /package/presets/security/{no-secrets-in-output.md → no-secrets-in-output/RULE.md} +0 -0
  92. /package/presets/security/{no-untrusted-input-in-commands-or-paths.md → no-untrusted-input-in-commands-or-paths/RULE.md} +0 -0
  93. /package/presets/security/{parameterized-queries.md → parameterized-queries/RULE.md} +0 -0
  94. /package/presets/typescript/async/{concurrent-independent-work.md → concurrent-independent-work/RULE.md} +0 -0
  95. /package/presets/typescript/async/{network-calls-have-timeouts.md → network-calls-have-timeouts/RULE.md} +0 -0
  96. /package/presets/typescript/design/{no-argument-mutation.md → no-argument-mutation/RULE.md} +0 -0
  97. /package/presets/typescript/errors/{no-swallowed-errors.md → no-swallowed-errors/RULE.md} +0 -0
  98. /package/presets/typescript/errors/{release-on-every-path.md → release-on-every-path/RULE.md} +0 -0
  99. /package/presets/typescript/testing/{assert-outcomes.md → assert-outcomes/RULE.md} +0 -0
  100. /package/presets/typescript/testing/{independent-tests.md → independent-tests/RULE.md} +0 -0
  101. /package/presets/typescript/types/{assertions-only-when-proven.md → assertions-only-when-proven/RULE.md} +0 -0
  102. /package/presets/typescript/types/{derive-dont-restate.md → derive-dont-restate/RULE.md} +0 -0
  103. /package/presets/typescript/types/{unrepresentable-invalid-states.md → unrepresentable-invalid-states/RULE.md} +0 -0
  104. /package/presets/typescript/types/{validate-external-data.md → validate-external-data/RULE.md} +0 -0
package/src/init.ts CHANGED
@@ -28,9 +28,13 @@ export interface InitOptions {
28
28
  * install`, rather than one that lints itself.
29
29
  */
30
30
  readonly shared?: string;
31
+ /** Who can see the shared repo alchemy creates. Defaults to public. */
32
+ readonly visibility?: Visibility;
31
33
  }
32
34
 
33
- // adhere-ignore-file rules/import-written-files -- src/index.ts exports initProject, so a Bun text import here breaks loading the package on Node and in Vitest (checked 2026-09-26), and the compiled executable has no files beside it to read.
35
+ export type Visibility = "public" | "private";
36
+
37
+ // adhere-ignore-file import-written-files -- src/index.ts exports initProject, so a Bun text import here breaks loading the package on Node and in Vitest (checked 2026-09-26), and the compiled executable has no files beside it to read.
34
38
  const CONFIG = `import { defineConfig } from "@drkmttr/adhere";
35
39
 
36
40
  export default defineConfig({
@@ -39,10 +43,11 @@ export default defineConfig({
39
43
  `;
40
44
 
41
45
  /**
42
- * A project for the config alone. A tsconfig's globs skip dot directories, so
43
- * without it the editor opens `.adhere/config.ts` outside any project, where
44
- * it cannot resolve `@drkmttr/adhere`, whose types only `bundler`, `node16`,
45
- * and `nodenext` resolution reach.
46
+ * A project for the config and any rules written in TypeScript. A tsconfig's
47
+ * globs skip dot directories, so without it the editor opens
48
+ * `.adhere/config.ts` outside any project, where it cannot resolve
49
+ * `@drkmttr/adhere`, whose types only `bundler`, `node16`, and `nodenext`
50
+ * resolution reach.
46
51
  */
47
52
  const TSCONFIG = `{
48
53
  "compilerOptions": {
@@ -54,7 +59,7 @@ const TSCONFIG = `{
54
59
  "skipLibCheck": true,
55
60
  "allowImportingTsExtensions": true
56
61
  },
57
- "include": ["config.ts"]
62
+ "include": ["**/*.ts"]
58
63
  }
59
64
  `;
60
65
 
@@ -113,8 +118,9 @@ export const handle = (id: string) =>
113
118
  const sharedReadme = (org: string, repo: string) => `# ${repo}
114
119
 
115
120
  Shared [adhere](https://github.com/darkmatter/adhere) rules for ${org}'s
116
- repositories. Each rule is a Markdown file in \`.adhere/\`, and a repo that
117
- copies it in has Jev judge its files against it when it runs \`adhere lint\`.
121
+ repositories. Each rule is a directory in \`.adhere/rules/\` that holds a
122
+ \`RULE.md\`, or a \`RULE.ts\`, and a repo that copies it in has Jev judge its
123
+ files against it when it runs \`adhere lint\`.
118
124
 
119
125
  ## Use these rules
120
126
 
@@ -127,13 +133,14 @@ adhere install ${org}/${repo}/style
127
133
  adhere install ${org}/${repo}/style/prefer-small-files
128
134
  \`\`\`
129
135
 
130
- The copies land in \`.adhere/${org}/${repo}/\` and are that repo's own from then
136
+ The copies land in \`.adhere/rules/${org}/${repo}/\` and are that repo's own from then
131
137
  on, to edit or to start other rules from. To take a later version of a rule,
132
138
  install it again with \`--force\`, which overwrites local edits.
133
139
 
134
140
  ## Add a rule
135
141
 
136
- Add a Markdown file under \`.adhere/<topic>/\`; its path is its id. See
142
+ Add a directory under \`.adhere/rules/<topic>/\` with the rule in a \`RULE.md\`;
143
+ its path is the rule's id. See
137
144
  [Rules as Markdown files](https://github.com/darkmatter/adhere#rules-as-markdown-files)
138
145
  and the [rule writing tips](https://github.com/darkmatter/adhere#rule-writing-tips).
139
146
  \`adhere validate\` checks each rule's wording and asks Jev whether any two
@@ -172,7 +179,11 @@ jobs:
172
179
  TYPESAFE_API_KEY: \${{ secrets.TYPESAFE_API_KEY }}
173
180
  `;
174
181
 
175
- const sharedStack = (org: string, repo: string) => `import * as Alchemy from "alchemy";
182
+ const sharedStack = (
183
+ org: string,
184
+ repo: string,
185
+ visibility: Visibility,
186
+ ) => `import * as Alchemy from "alchemy";
176
187
  import * as GitHub from "alchemy/GitHub";
177
188
  import * as Output from "alchemy/Output";
178
189
  import * as Config from "effect/Config";
@@ -188,7 +199,7 @@ export default Alchemy.Stack(
188
199
  owner,
189
200
  name: "${repo}",
190
201
  description: "Shared adhere rules",
191
- visibility: "private",
202
+ visibility: "${visibility}",
192
203
  });
193
204
  // CI's adhere validate asks Jev whether any two rules contradict. Naming
194
205
  // the repository by its output makes alchemy create it before the secret.
@@ -232,19 +243,22 @@ const SHARED_GITIGNORE = `node_modules/
232
243
 
233
244
  const CONFIG_PATH = CONFIG_FILES[0];
234
245
 
235
- /** The config's own project, which only a config at `.adhere/config.ts` needs. */
246
+ /** The project of the config at `.adhere/config.ts`, and of the rules written in TypeScript beside it. */
236
247
  const CONFIG_PROJECT = ".adhere/tsconfig.json";
237
248
 
238
249
  const EXAMPLES = [
239
- [".adhere/style/prefer-small-files.md", SMALL_FILES],
240
- [".adhere/style/name-domain-actions.md", NAME_EFFECTS],
250
+ [".adhere/rules/style/prefer-small-files/RULE.md", SMALL_FILES],
251
+ [".adhere/rules/style/name-domain-actions/RULE.md", NAME_EFFECTS],
241
252
  ] as const;
242
253
 
243
254
  /**
244
- * A shared repo has no config: \`adhere install\` copies only rule files, and
245
- * \`validate\` reads \`.adhere/\` without one.
255
+ * A shared repo has no config: \`adhere install\` copies only rules, and
256
+ * \`validate\` reads \`.adhere/rules/\` without one.
246
257
  */
247
- const filesFor = (shared: string | undefined): ReadonlyArray<readonly [string, string]> => {
258
+ const filesFor = (
259
+ shared: string | undefined,
260
+ visibility: Visibility,
261
+ ): ReadonlyArray<readonly [string, string]> => {
248
262
  if (shared === undefined) {
249
263
  return [[CONFIG_PATH, CONFIG], [CONFIG_PROJECT, TSCONFIG], ...EXAMPLES];
250
264
  }
@@ -252,7 +266,7 @@ const filesFor = (shared: string | undefined): ReadonlyArray<readonly [string, s
252
266
  return [
253
267
  ["README.md", sharedReadme(org, repo)],
254
268
  [".github/workflows/adhere.yaml", SHARED_WORKFLOW],
255
- ["alchemy.run.ts", sharedStack(org, repo)],
269
+ ["alchemy.run.ts", sharedStack(org, repo, visibility)],
256
270
  ["package.json", sharedPackage(repo)],
257
271
  [".gitignore", SHARED_GITIGNORE],
258
272
  ...EXAMPLES,
@@ -312,14 +326,11 @@ const packageManagerAt = async (root: string): Promise<PackageManager> => {
312
326
 
313
327
  /**
314
328
  * pnpm refuses to add to a workspace root without a flag. `-w` fails outside a
315
- * workspace, so the first one, which works in both, stands in for it. pnpm 11
316
- * also fails on msgpackr-extract's unapproved build script. It comes with
317
- * effect, and adhere never calls msgpackr, so the second flag lets pnpm skip
318
- * the build with a warning.
329
+ * workspace, so this one, which works in both, stands in for it.
319
330
  */
320
331
  const addFlags: Record<PackageManager, ReadonlyArray<string>> = {
321
332
  bun: [],
322
- pnpm: ["--ignore-workspace-root-check", "--config.strict-dep-builds=false"],
333
+ pnpm: ["--ignore-workspace-root-check"],
323
334
  yarn: [],
324
335
  npm: [],
325
336
  };
@@ -369,7 +380,7 @@ export const initProject = (
369
380
  const created: Array<string> = [];
370
381
  const skipped: Array<string> = [];
371
382
  const existing = options.shared === undefined ? yield* otherConfig(root) : undefined;
372
- for (const [path, contents] of filesFor(options.shared)) {
383
+ for (const [path, contents] of filesFor(options.shared, options.visibility ?? "public")) {
373
384
  if (path === CONFIG_PATH && existing !== undefined) {
374
385
  skipped.push(existing);
375
386
  continue;
package/src/install.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
- import { access, copyFile, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
2
+ import { access, cp, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join, sep } from "node:path";
5
- import { ADHERE_DIRECTORY, type RuleId } from "#config.ts";
5
+ import { RULES_DIRECTORY, type RuleId } from "#config.ts";
6
6
  import { parseRuleMarkdown } from "#markdown.ts";
7
7
  import { Effect } from "effect";
8
8
 
9
9
  /**
10
- * A GitHub repo whose `.adhere/` rules another repo copies: `org/repo`, then
10
+ * A GitHub repo whose `.adhere/rules/` another repo copies: `org/repo`, then
11
11
  * optionally a topic or a rule's id, then optionally `#` and a branch or tag,
12
12
  * as in `darkmatter/standards/data/brand-ports#v3`.
13
13
  */
@@ -63,43 +63,52 @@ const cloned = (source: Source) =>
63
63
  (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
64
64
  );
65
65
 
66
- /** The repo's root `.adhere/` rule files by id, as lint reads them there: every `*.md` outside `cache/`. */
66
+ /**
67
+ * The repo's rules in its root `.adhere/rules/`, as lint reads them there:
68
+ * each directory that holds a RULE.md or a RULE.ts, by its path, with that file.
69
+ */
67
70
  const rulesIn = (source: Source, clone: string) =>
68
71
  Effect.tryPromise({
69
72
  try: async () => {
70
- const directory = join(clone, ADHERE_DIRECTORY);
73
+ const directory = join(clone, RULES_DIRECTORY);
71
74
  const entries = await readdir(directory, { recursive: true }).catch(() => []);
72
- const ids = entries
73
- .filter((entry) => entry.endsWith(".md") && !entry.startsWith(`cache${sep}`))
74
- .map((entry): RuleId => entry.slice(0, -".md".length).split(sep).join("/"))
75
+ const rules = entries
76
+ .flatMap((entry) => {
77
+ const [, id, name] = /^(.+)\/(RULE\.(?:md|ts))$/.exec(entry.split(sep).join("/")) ?? [];
78
+ return id === undefined || name === undefined ? [] : [{ id: id as RuleId, name }];
79
+ })
75
80
  .filter(
76
- (id) => source.rule === "" || id === source.rule || id.startsWith(`${source.rule}/`),
81
+ ({ id }) => source.rule === "" || id === source.rule || id.startsWith(`${source.rule}/`),
77
82
  )
78
- .sort();
79
- if (ids.length === 0) {
83
+ .sort((a, b) => a.id.localeCompare(b.id));
84
+ if (rules.length === 0) {
80
85
  throw new Error(
81
86
  source.rule === ""
82
- ? `it has no rules in ${ADHERE_DIRECTORY}/`
83
- : `it has no rule or topic ${source.rule} in ${ADHERE_DIRECTORY}/`,
87
+ ? `it has no rules in ${RULES_DIRECTORY}/`
88
+ : `it has no rule or topic ${source.rule} in ${RULES_DIRECTORY}/`,
84
89
  );
85
90
  }
86
- return ids.map((id) => ({ id, file: join(directory, `${id}.md`) }));
91
+ return rules.map(({ id, name }) => ({ id, name, directory: join(directory, id) }));
87
92
  },
88
93
  catch: failed(`Nothing to copy from ${source.repo}`),
89
94
  });
90
95
 
91
- /** Each rule a source names, with its description. */
96
+ /**
97
+ * Each rule a source names, with its description. A rule written in
98
+ * TypeScript is listed without one: reading it would run the repo's code.
99
+ */
92
100
  export const listRules = (source: Source) =>
93
101
  Effect.scoped(
94
102
  Effect.gen(function* () {
95
103
  const rules = yield* rulesIn(source, yield* cloned(source));
96
- return yield* Effect.forEach(rules, ({ id, file }) =>
104
+ return yield* Effect.forEach(rules, ({ id, name, directory }) =>
97
105
  Effect.gen(function* () {
106
+ if (name === "RULE.ts") return { id, description: "(TypeScript, which list does not run)" };
98
107
  const text = yield* Effect.tryPromise({
99
- try: () => readFile(file, "utf8"),
108
+ try: () => readFile(join(directory, name), "utf8"),
100
109
  catch: failed(`Could not read ${id}`),
101
110
  });
102
- const rule = yield* parseRuleMarkdown(text, `${source.repo}/${id}.md`);
111
+ const rule = yield* parseRuleMarkdown(text, `${source.repo}/${id}/${name}`);
103
112
  return { id, description: rule.description };
104
113
  }),
105
114
  );
@@ -112,11 +121,12 @@ export interface InstallResult {
112
121
  }
113
122
 
114
123
  /**
115
- * Copies the rules a source names into `root`'s `.adhere/` under the source's
116
- * org and repo, so `data/ports` from acme/rules is the rule
117
- * `acme/rules/data/ports`, and two sources' rules never collide. They are the
118
- * project's own rules from then on. As with init, a file already there is
119
- * skipped unless `force`.
124
+ * Copies the rules a source names, each its whole directory, helpers and all,
125
+ * into `root`'s `.adhere/rules/` under the source's org and repo, so
126
+ * `data/ports` from acme/rules is the rule `acme/rules/data/ports`, and two
127
+ * sources' rules never collide. They are the project's own rules from then
128
+ * on, and a copied RULE.ts runs whenever lint does. As with init, a rule
129
+ * already there is skipped, unless `force` replaces its directory.
120
130
  */
121
131
  export const installRules = (root: string, source: Source, options: { readonly force?: boolean }) =>
122
132
  Effect.scoped(
@@ -124,8 +134,8 @@ export const installRules = (root: string, source: Source, options: { readonly f
124
134
  const rules = yield* rulesIn(source, yield* cloned(source));
125
135
  const created: Array<string> = [];
126
136
  const skipped: Array<string> = [];
127
- for (const { id, file } of rules) {
128
- const path = `${ADHERE_DIRECTORY}/${source.repo}/${id}.md`;
137
+ for (const { id, directory } of rules) {
138
+ const path = `${RULES_DIRECTORY}/${source.repo}/${id}/`;
129
139
  const target = join(root, path);
130
140
  const copied = yield* Effect.tryPromise({
131
141
  try: async () => {
@@ -134,8 +144,9 @@ export const installRules = (root: string, source: Source, options: { readonly f
134
144
  () => false,
135
145
  );
136
146
  if (exists && options.force !== true) return false;
147
+ await rm(target, { recursive: true, force: true });
137
148
  await mkdir(dirname(target), { recursive: true });
138
- await copyFile(file, target);
149
+ await cp(directory, target, { recursive: true });
139
150
  return true;
140
151
  },
141
152
  catch: failed(`Could not write ${path}`),
package/src/main.ts CHANGED
@@ -5,7 +5,7 @@ import { version } from "#version.ts";
5
5
  import { BunRuntime, BunServices } from "@effect/platform-bun";
6
6
  import { plugin } from "bun";
7
7
  import { Cause, Effect, Layer, Logger, References, Runtime } from "effect";
8
- import { Command } from "effect/unstable/cli";
8
+ import { Command } from "effect/cli";
9
9
 
10
10
  // A config file may import `@drkmttr/adhere`, for `defineConfig`. The compiled
11
11
  // executable resolves no packages from disk, so it answers that import with
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
  });
package/src/rules.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ConfigUnavailable,
3
3
  type Override,
4
- type Rule,
4
+ Rule,
5
5
  type RuleId,
6
6
  type RuleSource,
7
7
  SKIPPED_DIRECTORIES,
@@ -9,7 +9,7 @@ import {
9
9
  import { parseRuleMarkdown } from "#markdown.ts";
10
10
  import { walkFiles } from "#walk.ts";
11
11
  import { clearingStatus, countOf, Status } from "#services/Status.ts";
12
- import { Effect, FileSystem, Path, Record } from "effect";
12
+ import { Effect, FileSystem, Path, Record, Schema } from "effect";
13
13
 
14
14
  const refused = (directory: string) => (problem: { readonly message: string }) =>
15
15
  ConfigUnavailable.make({
@@ -40,6 +40,10 @@ export type RuleSet = ReadonlyArray<RuleEntry>;
40
40
 
41
41
  const ADHERE_SEGMENT = ".adhere";
42
42
  const CACHE_SEGMENT = "cache";
43
+ /** Where in a `.adhere/` its rules sit, one to a directory. */
44
+ const RULES_PREFIX = "rules/";
45
+ /** The file in a rule's directory that is the rule: Markdown, or TypeScript that default-exports `defineRule({...})`. */
46
+ const RULE_FILES: ReadonlySet<string> = new Set(["RULE.md", "RULE.ts"]);
43
47
 
44
48
  const normalized = (path: string): string => path.replaceAll("\\", "/");
45
49
 
@@ -48,12 +52,69 @@ const segmentsOf = (relative: string): ReadonlyArray<string> =>
48
52
  .split("/")
49
53
  .filter((segment) => segment.length > 0);
50
54
 
51
- const isMarkdown = (path: string): boolean => path.endsWith(".md");
55
+ /** A rule file, and its path in the directory it was read from. */
56
+ interface RuleFile {
57
+ readonly id: RuleId;
58
+ readonly leaf: string;
59
+ }
60
+
61
+ /**
62
+ * The rules among `leaves`, the paths in `directory`, whose rules sit under
63
+ * `prefix` one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`. Anything else
64
+ * in a rule's directory, such as a helper its RULE.ts imports, is not a rule.
65
+ * A directory with both files refuses, and so does a Markdown file outside
66
+ * every rule's directory, but a README.md: before rule directories, it was a
67
+ * rule, and the refusal says where it goes now to keep its id.
68
+ */
69
+ const ruleFilesIn = (
70
+ directory: string,
71
+ leaves: ReadonlyArray<string>,
72
+ prefix: string,
73
+ ): Effect.Effect<ReadonlyArray<RuleFile>, ConfigUnavailable> => {
74
+ const files = leaves.flatMap((leaf): ReadonlyArray<RuleFile> => {
75
+ const segments = segmentsOf(leaf.slice(prefix.length));
76
+ return leaf.startsWith(prefix) && segments.length > 1 && RULE_FILES.has(segments.at(-1) ?? "")
77
+ ? [{ id: segments.slice(0, -1).join("/"), leaf }]
78
+ : [];
79
+ });
80
+ const both = files.find((file, index) => files.findIndex(({ id }) => id === file.id) < index);
81
+ if (both !== undefined) {
82
+ return Effect.fail(
83
+ ConfigUnavailable.make({
84
+ message: `${directory}/${prefix}${both.id}: a rule is a RULE.md or a RULE.ts, not both`,
85
+ }),
86
+ );
87
+ }
88
+ const homes = files.map(({ leaf }) => leaf.slice(0, leaf.lastIndexOf("/") + 1));
89
+ const flat = leaves.filter((leaf) => {
90
+ const name = segmentsOf(leaf).at(-1) ?? "";
91
+ return (
92
+ leaf.endsWith(".md") &&
93
+ !RULE_FILES.has(name) &&
94
+ name !== "README.md" &&
95
+ !homes.some((home) => leaf.startsWith(home))
96
+ );
97
+ });
98
+ if (flat.length > 0) {
99
+ return Effect.fail(
100
+ ConfigUnavailable.make({
101
+ message: [
102
+ `Rules now live one to a directory, as ${directory}/${prefix}<id>/RULE.md or RULE.ts. Move each of these to keep its id:`,
103
+ ...flat.map(
104
+ (leaf) =>
105
+ ` ${directory}/${leaf} → ${directory}/${prefix}${leaf.slice(0, -".md".length)}/RULE.md`,
106
+ ),
107
+ ].join("\n"),
108
+ }),
109
+ );
110
+ }
111
+ return Effect.succeed(files);
112
+ };
52
113
 
53
114
  /**
54
115
  * Whether the rule walk reads into a directory: on the way to a `.adhere/`,
55
116
  * not a skipped tree or `.git`; inside one, anything but its cache, since a
56
- * directory there such as `.adhere/e2e/` is a rule topic.
117
+ * directory there such as `.adhere/rules/e2e/` is a rule topic.
57
118
  */
58
119
  const entersForRules = (relative: string): boolean => {
59
120
  const segments = segmentsOf(relative);
@@ -65,45 +126,30 @@ const entersForRules = (relative: string): boolean => {
65
126
  return name === ADHERE_SEGMENT || !(SKIPPED_DIRECTORIES.has(name) || name === ".git");
66
127
  };
67
128
 
68
- const isNestedRuleFile = (relative: string): boolean => {
69
- const segments = segmentsOf(relative);
70
- const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
71
- return (
72
- adhere >= 0 &&
73
- segments[adhere + 1] !== CACHE_SEGMENT &&
74
- isMarkdown(relative) &&
75
- // Only the path to the `.adhere/`: `.adhere/e2e/` is a rule topic, not a skip.
76
- !segments.slice(0, adhere).some((segment) => SKIPPED_DIRECTORIES.has(segment))
77
- );
78
- };
79
-
80
- const rulePathOf = (segments: ReadonlyArray<string>): ReadonlyArray<string> => {
81
- const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
82
- return adhere < 0 ? [] : segments.slice(adhere + 1);
83
- };
84
-
85
- const scopePathOf = (segments: ReadonlyArray<string>): ReadonlyArray<string> => {
86
- const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
87
- return adhere < 0 ? [] : segments.slice(0, adhere);
88
- };
89
-
90
- const ruleIdOf = (relative: string): RuleId => {
91
- const rulePath = rulePathOf(segmentsOf(relative));
92
- const leaf = rulePath.join("/");
93
- return leaf.slice(0, -".md".length);
129
+ /**
130
+ * Each `.adhere/` the walk found, by its path from the root, with the paths
131
+ * of the files in it. Only the path to a `.adhere/` counts against skipped
132
+ * trees: `.adhere/rules/e2e/` is a rule topic, not a skip.
133
+ */
134
+ const adhereDirectoriesOf = (
135
+ relatives: ReadonlyArray<string>,
136
+ ): ReadonlyMap<string, ReadonlyArray<string>> => {
137
+ const found = new Map<string, Array<string>>();
138
+ for (const relative of relatives) {
139
+ const segments = segmentsOf(relative);
140
+ const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
141
+ if (adhere < 0 || segments.slice(0, adhere).some((name) => SKIPPED_DIRECTORIES.has(name))) {
142
+ continue;
143
+ }
144
+ const directory = segments.slice(0, adhere + 1).join("/");
145
+ found.set(directory, [...(found.get(directory) ?? []), segments.slice(adhere + 1).join("/")]);
146
+ }
147
+ return found;
94
148
  };
95
149
 
96
150
  const joinPath = (path: Path.Path, root: string, parts: ReadonlyArray<string>): string =>
97
151
  parts.reduce((current, part) => path.join(current, part), root);
98
152
 
99
- const scopedRule = (path: Path.Path, root: string, relative: string, rule: Rule): RuleEntry => {
100
- const segments = segmentsOf(relative);
101
- const scopeParts = scopePathOf(segments);
102
- const scope = scopeParts.length === 0 ? path.resolve(root) : joinPath(path, root, scopeParts);
103
- const file = joinPath(path, root, segments);
104
- return { id: ruleIdOf(relative), file, scope, rule };
105
- };
106
-
107
153
  const isInside = (file: string, scope: string): boolean =>
108
154
  file === scope || file.startsWith(`${scope}/`);
109
155
 
@@ -111,9 +157,40 @@ const moreSpecific = (a: RuleEntry, b: RuleEntry): RuleEntry =>
111
157
  normalized(a.scope).length >= normalized(b.scope).length ? a : b;
112
158
 
113
159
  /**
114
- * Every `*.md` file under `directory`, validated and keyed by its path
115
- * relative to the directory without the extension: `basics/gen.md` is the
116
- * rule `basics/gen`.
160
+ * A rule written in TypeScript: the file's default export, decoded as a
161
+ * config's inline rule is, so a hook such as `appendState` comes with it.
162
+ * Like a config, the file is imported, so its code runs.
163
+ */
164
+ const importRule = Effect.fn("importRule")(function* (file: string) {
165
+ const path = yield* Path.Path;
166
+ const url = yield* path.toFileUrl(file).pipe(Effect.orDie);
167
+ const loaded = yield* Effect.tryPromise({
168
+ try: () => import(url.href) as Promise<{ readonly default?: unknown }>,
169
+ catch: (cause) =>
170
+ ConfigUnavailable.make({
171
+ message: `Could not load ${file}: ${cause instanceof Error ? cause.message : String(cause)}`,
172
+ }),
173
+ });
174
+ return yield* Schema.decodeUnknownEffect(Rule)(loaded.default).pipe(
175
+ Effect.mapError((problem) =>
176
+ ConfigUnavailable.make({
177
+ message: `${file}: a rule file must default-export defineRule({...}): ${problem.message}`,
178
+ }),
179
+ ),
180
+ );
181
+ });
182
+
183
+ /** A rule file's rule. A file that cannot be read refuses with the directory it was read from. */
184
+ const readRule = Effect.fn("readRule")(function* (file: string, directory: string) {
185
+ if (file.endsWith(".ts")) return yield* importRule(file);
186
+ const fs = yield* FileSystem.FileSystem;
187
+ const text = yield* fs.readFileString(file).pipe(Effect.mapError(refused(directory)));
188
+ return yield* parseRuleMarkdown(text, file);
189
+ });
190
+
191
+ /**
192
+ * Every rule under `directory`, validated and keyed by its directory's path
193
+ * in it: `basics/gen/RULE.md` is the rule `basics/gen`.
117
194
  */
118
195
  export const loadRules = Effect.fn("loadRules")(function* (directory: string) {
119
196
  const fs = yield* FileSystem.FileSystem;
@@ -121,19 +198,18 @@ export const loadRules = Effect.fn("loadRules")(function* (directory: string) {
121
198
  const entries = yield* fs
122
199
  .readDirectory(directory, { recursive: true })
123
200
  .pipe(Effect.mapError(refused(directory)));
124
- const files = entries.filter((entry) => entry.endsWith(".md")).sort();
125
- const rules = yield* Effect.forEach(files, (relative) =>
126
- Effect.gen(function* () {
127
- const file = path.join(directory, relative);
128
- const text = yield* fs.readFileString(file).pipe(Effect.mapError(refused(directory)));
129
- const id: RuleId = relative.slice(0, -".md".length).split(path.sep).join("/");
130
- return [id, yield* parseRuleMarkdown(text, file)] as const;
131
- }),
201
+ const files = yield* ruleFilesIn(directory, entries.map(normalized).sort(), "");
202
+ const rules = yield* Effect.forEach(files, ({ id, leaf }) =>
203
+ Effect.map(readRule(path.join(directory, leaf), directory), (rule) => [id, rule] as const),
132
204
  );
133
205
  return Record.fromEntries(rules) as Readonly<Record<RuleId, Rule>>;
134
206
  });
135
207
 
136
- /** Every nested `.adhere/` rule under `root`, scoped to its owner directory. */
208
+ /**
209
+ * Every rule in a `.adhere/rules/` under `root`, scoped to the directory that
210
+ * holds its `.adhere/`: `packages/api/.adhere/rules/data/ports/RULE.md` is
211
+ * `data/ports` for the files under `packages/api/`.
212
+ */
137
213
  export const loadAdhereRuleSet = Effect.fn("loadAdhereRuleSet")(function* (root: string) {
138
214
  const fs = yield* FileSystem.FileSystem;
139
215
  const path = yield* Path.Path;
@@ -145,14 +221,17 @@ export const loadAdhereRuleSet = Effect.fn("loadAdhereRuleSet")(function* (root:
145
221
  ),
146
222
  ),
147
223
  ).pipe(Effect.mapError(refused(root)));
148
- const files = entries.filter(isNestedRuleFile);
149
- return yield* Effect.forEach(files, (relative) =>
150
- Effect.gen(function* () {
151
- const file = joinPath(path, root, segmentsOf(relative));
152
- const text = yield* fs.readFileString(file).pipe(Effect.mapError(refused(root)));
153
- return scopedRule(path, root, relative, yield* parseRuleMarkdown(text, file));
224
+ const found = yield* Effect.forEach(adhereDirectoriesOf(entries), ([adhere, leaves]) =>
225
+ Effect.flatMap(ruleFilesIn(adhere, [...leaves].sort(), RULES_PREFIX), (files) => {
226
+ const owner = segmentsOf(adhere).slice(0, -1);
227
+ const scope = owner.length === 0 ? path.resolve(root) : joinPath(path, root, owner);
228
+ return Effect.forEach(files, ({ id, leaf }) => {
229
+ const file = joinPath(path, root, segmentsOf(`${adhere}/${leaf}`));
230
+ return Effect.map(readRule(file, root), (rule): RuleEntry => ({ id, file, scope, rule }));
231
+ });
154
232
  }),
155
233
  );
234
+ return found.flat();
156
235
  });
157
236
 
158
237
  /** Rules as given, or loaded from the directory a source names. */
@@ -1,5 +1,4 @@
1
1
  import {
2
- ADHERE_DIRECTORY,
3
2
  AdhereConfig as AdhereConfigSchema,
4
3
  CONFIG_FILES,
5
4
  ConfigUnavailable,
@@ -10,6 +9,7 @@ import {
10
9
  presetsOf,
11
10
  type ResolvedConfig,
12
11
  resolveConfig,
12
+ RULES_DIRECTORY,
13
13
  } from "#config.ts";
14
14
  import { type PresetName, presetOf, presets, wholePresetOf } from "#presets.ts";
15
15
  import {
@@ -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>()(
@@ -94,7 +95,7 @@ export const AdhereConfigLive = (flags: Flags) =>
94
95
  const hasProjectRules = discoveredEntries.length > 0;
95
96
  if (configFile === undefined && !hasProjectRules && !hasPreset) {
96
97
  return yield* ConfigUnavailable.make({
97
- message: `Nothing to audit against: no config file (${CONFIG_FILES.join(", ")}), no ${ADHERE_DIRECTORY}/ directory of rules, and no --preset. Pass --preset effect to use the built-in rules.`,
98
+ message: `Nothing to audit against: no config file (${CONFIG_FILES.join(", ")}), no rules in ${RULES_DIRECTORY}/, and no --preset. Pass --preset effect to use the built-in rules.`,
98
99
  });
99
100
  }
100
101
  const projectEntries: RuleSet =
@@ -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
  );
@@ -46,10 +46,10 @@ export const CredentialsLive = Layer.effect(Credentials)(
46
46
  const fs = yield* FileSystem.FileSystem;
47
47
  const path = yield* Path.Path;
48
48
 
49
- const file = Config.nonEmptyString("XDG_CONFIG_HOME").pipe(
49
+ const file = Config.NonEmptyString("XDG_CONFIG_HOME").pipe(
50
50
  Config.orElse(() =>
51
- Config.nonEmptyString("HOME").pipe(
52
- Config.orElse(() => Config.nonEmptyString("USERPROFILE")),
51
+ Config.NonEmptyString("HOME").pipe(
52
+ Config.orElse(() => Config.NonEmptyString("USERPROFILE")),
53
53
  Config.map((home) => path.join(home, ".config")),
54
54
  ),
55
55
  ),
@@ -78,7 +78,7 @@ export const CredentialsLive = Layer.effect(Credentials)(
78
78
 
79
79
  // Read when a request needs the key, not before: a cached run needs none.
80
80
  const apiKey = Effect.fn("Credentials.apiKey")(function* () {
81
- const fromEnvironment = yield* Config.option(Config.redacted("TYPESAFE_API_KEY")).pipe(
81
+ const fromEnvironment = yield* Config.option(Config.Redacted("TYPESAFE_API_KEY")).pipe(
82
82
  Effect.mapError((problem) => refused(`Cannot read TYPESAFE_API_KEY: ${problem.message}`)),
83
83
  );
84
84
  if (Option.isSome(fromEnvironment)) return fromEnvironment.value;