@drkmttr/adhere 0.11.0 → 0.13.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 (102) hide show
  1. package/README.md +184 -37
  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 +206 -118
  8. package/skills/adhere-fix/SKILL.md +9 -8
  9. package/skills/adhere-setup/SKILL.md +244 -0
  10. package/src/cli.ts +114 -25
  11. package/src/config.ts +50 -3
  12. package/src/index.ts +2 -0
  13. package/src/init.ts +193 -27
  14. package/src/install.ts +158 -0
  15. package/src/main.ts +1 -1
  16. package/src/rules.ts +135 -56
  17. package/src/services/AdhereConfig.ts +2 -2
  18. package/src/services/Credentials.ts +4 -4
  19. package/src/services/Jev.http.ts +76 -10
  20. package/src/services/Jev.ts +39 -10
  21. package/src/workflows/audit.ts +23 -12
  22. package/src/workflows/format.ts +172 -35
  23. /package/presets/alchemy/apis/{match-remote-errors-by-tag.md → match-remote-errors-by-tag/RULE.md} +0 -0
  24. /package/presets/alchemy/apis/{schemas-at-trust-boundaries.md → schemas-at-trust-boundaries/RULE.md} +0 -0
  25. /package/presets/alchemy/data/{commit-migrations-then-deploy.md → commit-migrations-then-deploy/RULE.md} +0 -0
  26. /package/presets/alchemy/data/{hyperdrive-direct-origin.md → hyperdrive-direct-origin/RULE.md} +0 -0
  27. /package/presets/alchemy/data/{migrations-use-direct-url.md → migrations-use-direct-url/RULE.md} +0 -0
  28. /package/presets/alchemy/data/{never-disable-tls-verification.md → never-disable-tls-verification/RULE.md} +0 -0
  29. /package/presets/alchemy/data/{private-endpoint-at-runtime.md → private-endpoint-at-runtime/RULE.md} +0 -0
  30. /package/presets/alchemy/data/{single-migration-owner.md → single-migration-owner/RULE.md} +0 -0
  31. /package/presets/alchemy/durable/{idempotent-event-handlers.md → idempotent-event-handlers/RULE.md} +0 -0
  32. /package/presets/alchemy/durable/{io-inside-steps.md → io-inside-steps/RULE.md} +0 -0
  33. /package/presets/alchemy/durable/{no-self-triggering-bucket-writes.md → no-self-triggering-bucket-writes/RULE.md} +0 -0
  34. /package/presets/alchemy/durable/{stable-step-names.md → stable-step-names/RULE.md} +0 -0
  35. /package/presets/alchemy/durable/{webhook-state-is-truth.md → webhook-state-is-truth/RULE.md} +0 -0
  36. /package/presets/alchemy/providers/{diff-guards-unresolved-props.md → diff-guards-unresolved-props/RULE.md} +0 -0
  37. /package/presets/alchemy/providers/{idempotent-delete.md → idempotent-delete/RULE.md} +0 -0
  38. /package/presets/alchemy/providers/{lazy-layer-construction.md → lazy-layer-construction/RULE.md} +0 -0
  39. /package/presets/alchemy/providers/{reconcile-from-observed-state.md → reconcile-from-observed-state/RULE.md} +0 -0
  40. /package/presets/alchemy/resources/{anchor-paths-with-import-meta.md → anchor-paths-with-import-meta/RULE.md} +0 -0
  41. /package/presets/alchemy/resources/{idempotent-actions-and-seeds.md → idempotent-actions-and-seeds/RULE.md} +0 -0
  42. /package/presets/alchemy/resources/{previews-reference-shared-resources.md → previews-reference-shared-resources/RULE.md} +0 -0
  43. /package/presets/alchemy/resources/{protect-irreplaceable-data.md → protect-irreplaceable-data/RULE.md} +0 -0
  44. /package/presets/alchemy/resources/{remote-state-for-ci.md → remote-state-for-ci/RULE.md} +0 -0
  45. /package/presets/alchemy/resources/{stable-logical-ids.md → stable-logical-ids/RULE.md} +0 -0
  46. /package/presets/alchemy/resources/{stage-unique-physical-names.md → stage-unique-physical-names/RULE.md} +0 -0
  47. /package/presets/alchemy/runtime/{bind-tag-not-implementation.md → bind-tag-not-implementation/RULE.md} +0 -0
  48. /package/presets/alchemy/runtime/{deterministic-config-defaults.md → deterministic-config-defaults/RULE.md} +0 -0
  49. /package/presets/alchemy/runtime/{disposables-in-request-scope.md → disposables-in-request-scope/RULE.md} +0 -0
  50. /package/presets/alchemy/runtime/{lambda-no-post-response-work.md → lambda-no-post-response-work/RULE.md} +0 -0
  51. /package/presets/alchemy/runtime/{no-per-request-work-at-construction.md → no-per-request-work-at-construction/RULE.md} +0 -0
  52. /package/presets/alchemy/runtime/{resolve-config-and-bindings-at-construction.md → resolve-config-and-bindings-at-construction/RULE.md} +0 -0
  53. /package/presets/alchemy/secrets/{let-alchemy-mint-infra-secrets.md → let-alchemy-mint-infra-secrets/RULE.md} +0 -0
  54. /package/presets/alchemy/secrets/{none-in-browser-env.md → none-in-browser-env/RULE.md} +0 -0
  55. /package/presets/alchemy/secrets/{redacted-until-use.md → redacted-until-use/RULE.md} +0 -0
  56. /package/presets/alchemy/secrets/{unwrap-before-compare-or-send.md → unwrap-before-compare-or-send/RULE.md} +0 -0
  57. /package/presets/alchemy/security/{authorize-public-endpoints.md → authorize-public-endpoints/RULE.md} +0 -0
  58. /package/presets/alchemy/security/{github-secret-not-variable.md → github-secret-not-variable/RULE.md} +0 -0
  59. /package/presets/alchemy/security/{identity-from-verified-session.md → identity-from-verified-session/RULE.md} +0 -0
  60. /package/presets/alchemy/security/{keep-auth-protections-on.md → keep-auth-protections-on/RULE.md} +0 -0
  61. /package/presets/alchemy/security/{least-privilege-bindings.md → least-privilege-bindings/RULE.md} +0 -0
  62. /package/presets/alchemy/security/{least-privilege-ci-credentials.md → least-privilege-ci-credentials/RULE.md} +0 -0
  63. /package/presets/alchemy/security/{lookup-failure-is-not-anonymous.md → lookup-failure-is-not-anonymous/RULE.md} +0 -0
  64. /package/presets/effect/basics/{gen-for-sequencing.md → gen-for-sequencing/RULE.md} +0 -0
  65. /package/presets/effect/config/{tests-provide-values-directly.md → tests-provide-values-directly/RULE.md} +0 -0
  66. /package/presets/effect/data/{brand-meaningful-primitives.md → brand-meaningful-primitives/RULE.md} +0 -0
  67. /package/presets/effect/data/{variants-are-tagged-unions.md → variants-are-tagged-unions/RULE.md} +0 -0
  68. /package/presets/effect/errors/{catch-defects-at-boundaries-only.md → catch-defects-at-boundaries-only/RULE.md} +0 -0
  69. /package/presets/effect/errors/{defects-for-bugs.md → defects-for-bugs/RULE.md} +0 -0
  70. /package/presets/effect/errors/{wrap-external-errors.md → wrap-external-errors/RULE.md} +0 -0
  71. /package/presets/effect/services/{dependencies-through-layers.md → dependencies-through-layers/RULE.md} +0 -0
  72. /package/presets/effect/services/{fresh-layer-per-test.md → fresh-layer-per-test/RULE.md} +0 -0
  73. /package/presets/effect/services/{memoize-parameterized-layers.md → memoize-parameterized-layers/RULE.md} +0 -0
  74. /package/presets/effect/services/{no-mutable-state.md → no-mutable-state/RULE.md} +0 -0
  75. /package/presets/effect/services/{operations-have-no-requirements.md → operations-have-no-requirements/RULE.md} +0 -0
  76. /package/presets/effect/services/{test-layers-are-in-memory.md → test-layers-are-in-memory/RULE.md} +0 -0
  77. /package/presets/effect/testing/{test-clock-for-time.md → test-clock-for-time/RULE.md} +0 -0
  78. /package/presets/react/effects/{clean-up-what-they-start.md → clean-up-what-they-start/RULE.md} +0 -0
  79. /package/presets/react/effects/{event-logic-in-handlers.md → event-logic-in-handlers/RULE.md} +0 -0
  80. /package/presets/react/effects/{external-stores-use-sync-external-store.md → external-stores-use-sync-external-store/RULE.md} +0 -0
  81. /package/presets/react/effects/{fetches-ignore-stale-responses.md → fetches-ignore-stale-responses/RULE.md} +0 -0
  82. /package/presets/react/effects/{notify-parents-in-handlers.md → notify-parents-in-handlers/RULE.md} +0 -0
  83. /package/presets/react/server/{no-private-data-to-client.md → no-private-data-to-client/RULE.md} +0 -0
  84. /package/presets/react/server/{server-functions-authorize-and-validate.md → server-functions-authorize-and-validate/RULE.md} +0 -0
  85. /package/presets/react/state/{no-contradictory-state.md → no-contradictory-state/RULE.md} +0 -0
  86. /package/presets/react/state/{no-props-copied-into-state.md → no-props-copied-into-state/RULE.md} +0 -0
  87. /package/presets/react/state/{store-ids-not-copies.md → store-ids-not-copies/RULE.md} +0 -0
  88. /package/presets/security/{constant-time-secret-compare.md → constant-time-secret-compare/RULE.md} +0 -0
  89. /package/presets/security/{no-secrets-in-output.md → no-secrets-in-output/RULE.md} +0 -0
  90. /package/presets/security/{no-untrusted-input-in-commands-or-paths.md → no-untrusted-input-in-commands-or-paths/RULE.md} +0 -0
  91. /package/presets/security/{parameterized-queries.md → parameterized-queries/RULE.md} +0 -0
  92. /package/presets/typescript/async/{concurrent-independent-work.md → concurrent-independent-work/RULE.md} +0 -0
  93. /package/presets/typescript/async/{network-calls-have-timeouts.md → network-calls-have-timeouts/RULE.md} +0 -0
  94. /package/presets/typescript/design/{no-argument-mutation.md → no-argument-mutation/RULE.md} +0 -0
  95. /package/presets/typescript/errors/{no-swallowed-errors.md → no-swallowed-errors/RULE.md} +0 -0
  96. /package/presets/typescript/errors/{release-on-every-path.md → release-on-every-path/RULE.md} +0 -0
  97. /package/presets/typescript/testing/{assert-outcomes.md → assert-outcomes/RULE.md} +0 -0
  98. /package/presets/typescript/testing/{independent-tests.md → independent-tests/RULE.md} +0 -0
  99. /package/presets/typescript/types/{assertions-only-when-proven.md → assertions-only-when-proven/RULE.md} +0 -0
  100. /package/presets/typescript/types/{derive-dont-restate.md → derive-dont-restate/RULE.md} +0 -0
  101. /package/presets/typescript/types/{unrepresentable-invalid-states.md → unrepresentable-invalid-states/RULE.md} +0 -0
  102. /package/presets/typescript/types/{validate-external-data.md → validate-external-data/RULE.md} +0 -0
@@ -0,0 +1,244 @@
1
+ ---
2
+ name: adhere-setup
3
+ description: Set adhere up in a repository with the user. Scan the repo for conventions a normal linter cannot check and draft them as rules, add fitting presets and rules from a shared org repo, let the user preview and choose which to import, validate them, walk through the first lint, and add adhere to CI. Use when a repo has no adhere rules yet, or the user asks to set up, adopt, or onboard adhere.
4
+ ---
5
+
6
+ # Set up adhere in a repo
7
+
8
+ You and the user go from no rules to adhere passing in CI. The user decides
9
+ what gets imported, what it may cost, and what reaches GitHub; you do the
10
+ reading, drafting, and running. The adhere skill, which `adhere skill`
11
+ prints, is the reference for the rule format, the config, and tuning: read it
12
+ before step 3.
13
+
14
+ At each point marked **Ask**, stop and wait for the answer. Use a structured
15
+ question tool if you have one; otherwise ask in plain text with numbered
16
+ choices.
17
+
18
+ ## 1. Check the tools
19
+
20
+ Use the package manager the repo already uses, for every command in this
21
+ skill; never bring in another. Tell it by the lockfile beside `package.json`,
22
+ as `adhere init` does: `bun.lock` or `bun.lockb` for bun, `pnpm-lock.yaml` for
23
+ pnpm, `yarn.lock` for yarn, `package-lock.json` for npm. Without a lockfile,
24
+ the `packageManager` field in `package.json` names it; with neither, **Ask**.
25
+
26
+ | Manager | Add adhere | Run adhere |
27
+ | ------- | -------------------------------- | ------------------ |
28
+ | bun | `bun add -D @drkmttr/adhere` | `bunx adhere` |
29
+ | pnpm | `pnpm add -D @drkmttr/adhere` | `pnpm exec adhere` |
30
+ | yarn | `yarn add -D @drkmttr/adhere` | `yarn adhere` |
31
+ | npm | `npm install -D @drkmttr/adhere` | `npx adhere` |
32
+
33
+ In a repo with a `package.json`, add adhere as a dev dependency with its
34
+ manager, so the repo pins the version the team and CI run. From then on,
35
+ wherever this skill says `adhere`, run it the manager's way, as in
36
+ `pnpm exec adhere lint`.
37
+
38
+ With pnpm, at a workspace root, add `--ignore-workspace-root-check`.
39
+
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`.
43
+
44
+ Validate and lint need a TypeSafe AI API key: `TYPESAFE_API_KEY`, or one saved
45
+ by `adhere login`, which the user runs; never ask for the key in chat or print
46
+ it.
47
+
48
+ ## 2. Find candidates
49
+
50
+ Gather candidates from three sources. Keep a note, for each, of where it came
51
+ from and the evidence.
52
+
53
+ **The repo.** Read, noting every "we always" and "never":
54
+
55
+ - `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, convention sections of the
56
+ README, `docs/`, ADRs.
57
+ - Lint and type configuration, to learn what is already enforced exactly.
58
+ - Review feedback, when `gh` works: `gh pr list --state merged --limit 20`,
59
+ then `gh pr view <n> --comments` on a few. Repeated review comments are
60
+ unwritten rules.
61
+ - The code: the module others copy, the canonical service, error type, and
62
+ test.
63
+
64
+ Keep only conventions that need judgment: about intent, shape, or placement.
65
+ Drop any a regex, an import check, or the type checker could flag every time,
66
+ and note where it belongs instead. Drop any a preset below already covers.
67
+
68
+ **Presets.** Match the stack: `typescript` for TypeScript, `react` for React,
69
+ `security` for code that handles secrets, SQL, shell commands, or paths,
70
+ `effect` for Effect, `alchemy` for alchemy stacks. Read their rules under
71
+ `presets/` in the installed package or at
72
+ https://github.com/darkmatter/adhere/tree/main/presets, to describe them and
73
+ to avoid drafting duplicates.
74
+
75
+ **A shared repo.** **Ask** whether the organization keeps adhere rules in a
76
+ repo. If it does, `adhere list org/repo` prints its rules with their
77
+ descriptions.
78
+
79
+ ## 3. Draft the repo's rules
80
+
81
+ Write each repo candidate as a rule file, worded as the adhere skill says: one
82
+ sentence with "must" and "never" (or "should" and "should not"), one `must`
83
+ block of real code from the repo, and one `never` block, ideally a violation
84
+ from the repo or its history.
85
+
86
+ Write the drafts outside the repo, in a scratch directory such as
87
+ `$(mktemp -d)/<topic>/<slug>/RULE.md`, so nothing lands in `.adhere/` before the
88
+ user picks it.
89
+
90
+ ## 4. Preview and choose
91
+
92
+ Show one numbered list of every candidate, grouped by source:
93
+
94
+ ```text
95
+ From this repo
96
+ 1. errors/tagged-errors Domain errors must be Schema.TaggedError classes, never thrown Error.
97
+ Evidence: AGENTS.md "Errors"; src/billing/errors.ts
98
+ 2. services/config-service Business logic must read config through ConfigService, never Config.* directly.
99
+ Evidence: review comments on #412, #398
100
+ Presets
101
+ 3. typescript 11 rules: runtime checks at boundaries, no swallowed errors, ...
102
+ 4. security 4 rules: no secrets in output, parameterized SQL, ...
103
+ From acme/standards
104
+ 5. data/brand-ports A port must be a branded, range-checked integer, never a bare number.
105
+ Dropped as a linter's job
106
+ - "no default exports": eslint import/no-default-export
107
+ ```
108
+
109
+ For a preset, offer the whole preset or its topics. Show a draft's full text
110
+ when the user asks for it.
111
+
112
+ **Ask** which to import: all, some by number, or none, and whether to change
113
+ the wording of any. Revise drafts the user wants changed and show them again.
114
+
115
+ ## 5. Import the chosen rules
116
+
117
+ - Without a config, run `adhere init`. It also writes two example rules under
118
+ `.adhere/rules/style/`; delete them unless the user chose them.
119
+ - Copy each chosen draft to `.adhere/rules/<topic>/<slug>/RULE.md`.
120
+ - Add chosen presets, or topics, to `presets` in `.adhere/config.ts`.
121
+ - Install chosen shared rules: `adhere install org/repo/<id>` for each, or
122
+ `adhere install org/repo` for all of them. They land in `.adhere/rules/org/repo/`.
123
+ - Add `exclude` globs for generated or vendored code the default skips miss.
124
+
125
+ ## 6. Validate
126
+
127
+ ```sh
128
+ adhere validate
129
+ ```
130
+
131
+ It reports rules worded against the tips, rules a linter could check, and
132
+ contradictions between rules that apply to the same files. Fix the wording
133
+ yourself. For a linter-checkable rule or a contradiction, **Ask** the user
134
+ which to keep, and apply it. Run it again until it reports no contradictions.
135
+
136
+ ## 7. The first lint
137
+
138
+ ```sh
139
+ adhere lint --limit 0
140
+ ```
141
+
142
+ This shows the plan and judges nothing: files, rules, requests, and the
143
+ estimated cost. Tell the user those numbers. On a large repo, offer to start
144
+ with a subtree, such as `--filter 'src/**'`, or a `--limit`. **Ask** before
145
+ sending, then run it:
146
+
147
+ ```sh
148
+ adhere lint --yes
149
+ ```
150
+
151
+ Walk the user through the report:
152
+
153
+ 1. How to read a finding: the rule id, the description, its confidence and
154
+ context scores, the underlined line, and the warning when the file may not
155
+ show enough.
156
+ 2. Findings grouped by rule, the highest probabilities first. Check a few from
157
+ each rule against the code, and say which look real and which do not.
158
+ 3. For each noisy rule, propose one change from the adhere skill's "Tuning a
159
+ rule": narrow the wording, scope it, raise its threshold, make it a
160
+ warning, or turn it off. **Ask**, apply what the user accepts, and run
161
+ lint again. Only the edited rules are judged again.
162
+
163
+ CI fails on any error finding, so lint must exit 0 before step 8. **Ask** how
164
+ to get there: fix the real findings now with the adhere-fix skill
165
+ (`adhere skill fix`), make a rule a warning until its findings are fixed,
166
+ or suppress false findings with `adhere-ignore` comments that give the reason.
167
+
168
+ Commit `.adhere/` with the cache, so the team and CI reuse these judgments.
169
+ Offer to add `.adhere/cache/** linguist-generated -diff` to `.gitattributes`.
170
+
171
+ ## 8. Add adhere to CI
172
+
173
+ **Ask** before writing a workflow and before touching the repo's secrets.
174
+
175
+ If the repo's CI already installs its dependencies, add adhere as a job or a
176
+ step there, with the same setup, so it runs the pinned adhere the way the
177
+ rest of CI runs its tools. Otherwise, for GitHub Actions, add
178
+ `.github/workflows/adhere.yaml`:
179
+
180
+ ```yaml
181
+ name: adhere
182
+
183
+ on:
184
+ pull_request:
185
+ push:
186
+ branches: [main]
187
+
188
+ jobs:
189
+ lint:
190
+ runs-on: ubuntu-latest
191
+ steps:
192
+ - uses: actions/checkout@v4
193
+ # Set up the repo's package manager and install, from the table below.
194
+ - run: pnpm exec adhere lint --yes # the manager's way of running adhere
195
+ env:
196
+ TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
197
+ ```
198
+
199
+ | Manager | Setup steps | Install |
200
+ | ------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------ |
201
+ | bun | `uses: oven-sh/setup-bun@v2` | `bun install --frozen-lockfile` |
202
+ | pnpm | `uses: pnpm/action-setup@v4`, then `uses: actions/setup-node@v4` with `cache: pnpm` | `pnpm install --frozen-lockfile` |
203
+ | yarn | `uses: actions/setup-node@v4` with `cache: yarn`, then `run: corepack enable` | `yarn install --immutable`, or `--frozen-lockfile` on Yarn 1 |
204
+ | npm | `uses: actions/setup-node@v4` with `cache: npm` | `npm ci` |
205
+
206
+ Pin the versions the repo already uses: bun's `bun-version`, Node's
207
+ `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`.
211
+
212
+ Add `--deny-warnings` if the user wants warnings to fail the job too. Match
213
+ the branch to the repo's default branch. For another CI system, write the
214
+ same job: check out, install, run adhere's `lint --yes` with the key from a
215
+ secret.
216
+
217
+ The user adds the key as a secret: `gh secret set TYPESAFE_API_KEY`, which
218
+ prompts for it, or in the repo's settings. Pull requests from forks get no
219
+ secrets, so lint fails there on any file not in the cache.
220
+
221
+ Where lint gates merges, anyone who can commit can also commit a cache file
222
+ saying their code passes. To lint a pull request against the cache as merged,
223
+ fetch the base branch and restore its cache before linting:
224
+
225
+ ```yaml
226
+ - uses: actions/checkout@v4
227
+ with:
228
+ fetch-depth: 0
229
+ - if: github.event_name == 'pull_request'
230
+ run: rm -rf .adhere/cache && git checkout origin/${{ github.base_ref }} -- .adhere/cache
231
+ ```
232
+
233
+ This needs the cache committed on the base branch first.
234
+
235
+ ## 9. Report
236
+
237
+ Tell the user:
238
+
239
+ - the rules imported, by source, and where they are;
240
+ - the presets and shared repo named in the config or installed;
241
+ - the candidates dropped as a linter's job, with where each belongs;
242
+ - the last lint's findings, and what was fixed, made a warning, or suppressed;
243
+ - the CI workflow and whether the secret is set;
244
+ - what is left for them: pushing, setting the secret, fixing remaining findings.
package/src/cli.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  import type { Flags } from "#config.ts";
24
24
  import { findContradictions, formatContradictions } from "#contradictions.ts";
25
25
  import { type Install, initProject } from "#init.ts";
26
+ import { installRules, listRules, parseSource } from "#install.ts";
26
27
  import { formatLinterCheck, tallyProjectRules } from "#mechanical.ts";
27
28
  import { type PresetName, presetNames } from "#presets.ts";
28
29
  import { globalRuleSet } from "#rules.ts";
@@ -42,14 +43,15 @@ import {
42
43
  Stdio,
43
44
  Stream,
44
45
  } from "effect";
45
- import { Argument, Command, Flag, Prompt } from "effect/unstable/cli";
46
- import { FetchHttpClient } from "effect/unstable/http";
46
+ import { Argument, Command, Flag, Prompt } from "effect/cli";
47
+ import { FetchHttpClient } from "effect/http";
47
48
  // A Bun text import (https://bun.sh/docs/bundler/loaders#text): the file's
48
49
  // contents become a string at bundle time, so the compiled binary carries the
49
50
  // skill without an --asset flag. `skills/` sits beside `src/`, outside the `#`
50
51
  // import map, hence the relative path.
51
52
  import skill from "../skills/adhere/SKILL.md" with { type: "text" };
52
53
  import fixSkill from "../skills/adhere-fix/SKILL.md" with { type: "text" };
54
+ import setupSkill from "../skills/adhere-setup/SKILL.md" with { type: "text" };
53
55
 
54
56
  class FindingsReported extends Schema.TaggedError<FindingsReported>()("FindingsReported", {
55
57
  count: Schema.Finite,
@@ -74,7 +76,7 @@ const presetsIn = (values: ReadonlyArray<string>): ReadonlyArray<string> =>
74
76
  const isPresetName = (name: string): name is PresetName =>
75
77
  (presetNames as ReadonlyArray<string>).includes(name);
76
78
 
77
- const preset = Flag.string("preset").pipe(
79
+ const preset = Flag.String("preset").pipe(
78
80
  Flag.atLeast(0),
79
81
  Flag.filterMap(
80
82
  (values): Option.Option<ReadonlyArray<PresetName>> => {
@@ -92,21 +94,21 @@ const preset = Flag.string("preset").pipe(
92
94
  ),
93
95
  );
94
96
 
95
- const threshold = Flag.float("threshold").pipe(
97
+ const threshold = Flag.Finite("threshold").pipe(
96
98
  Flag.optional,
97
99
  Flag.withDescription(
98
100
  "Report a rule when Jev's probability is above this value, 0 to 1. Replaces the config's threshold; per-rule thresholds still apply.",
99
101
  ),
100
102
  );
101
103
 
102
- const sufficiencyThreshold = Flag.float("sufficiency-threshold").pipe(
104
+ const sufficiencyThreshold = Flag.Finite("sufficiency-threshold").pipe(
103
105
  Flag.optional,
104
106
  Flag.withDescription(
105
107
  "Warn on a finding when Jev's probability that the file shows enough to decide it is below this value, 0 to 1. Replaces the config's sufficiencyThreshold; the default is 0.6.",
106
108
  ),
107
109
  );
108
110
 
109
- const limit = Flag.integer("limit").pipe(
111
+ const limit = Flag.Int("limit").pipe(
110
112
  Flag.filter(
111
113
  (checks) => checks >= 0,
112
114
  (checks) => `--limit is a number of checks, 0 or more, not ${checks}.`,
@@ -117,7 +119,7 @@ const limit = Flag.integer("limit").pipe(
117
119
  ),
118
120
  );
119
121
 
120
- const rpm = Flag.integer("rpm").pipe(
122
+ const rpm = Flag.Int("rpm").pipe(
121
123
  Flag.filter(
122
124
  (requests) => requests > 0,
123
125
  (requests) => `--rpm is a number of requests a minute, more than 0, not ${requests}.`,
@@ -126,19 +128,19 @@ const rpm = Flag.integer("rpm").pipe(
126
128
  Flag.withDescription("Send at most this many requests to Jev a minute, evenly spaced."),
127
129
  );
128
130
 
129
- const filter = Flag.string("filter").pipe(
131
+ const filter = Flag.String("filter").pipe(
130
132
  Flag.atLeast(0),
131
133
  Flag.withDescription(
132
134
  "Read only files whose path from the working directory matches this glob, such as 'src/**' or '**/*.service.ts'. Repeat it for more; a pattern starting with ! leaves out what it matches.",
133
135
  ),
134
136
  );
135
137
 
136
- const denyWarnings = Flag.boolean("deny-warnings").pipe(
138
+ const denyWarnings = Flag.Boolean("deny-warnings").pipe(
137
139
  Flag.withDefault(false),
138
140
  Flag.withDescription("Fail the run on warnings as well as errors."),
139
141
  );
140
142
 
141
- const yes = Flag.boolean("yes").pipe(
143
+ const yes = Flag.Boolean("yes").pipe(
142
144
  Flag.withAlias("y"),
143
145
  Flag.withDefault(false),
144
146
  Flag.withDescription(
@@ -211,7 +213,7 @@ const statusLayer = Layer.effect(Status)(
211
213
  }),
212
214
  );
213
215
 
214
- const force = Flag.boolean("force").pipe(
216
+ const force = Flag.Boolean("force").pipe(
215
217
  Flag.withDefault(false),
216
218
  Flag.withDescription("Overwrite existing scaffold files."),
217
219
  );
@@ -238,7 +240,7 @@ export const lintCommand = Command.make(
238
240
  const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
239
241
  if (plan.requests > 0 && interactive && !input.yes) {
240
242
  const send = yield* Prompt.run(
241
- Prompt.confirm({ message: sendQuestion(plan), initial: true }),
243
+ Prompt.Confirm({ message: sendQuestion(plan), initial: true }),
242
244
  );
243
245
  if (!send) {
244
246
  yield* toStderr("Nothing sent.\n");
@@ -255,7 +257,14 @@ export const lintCommand = Command.make(
255
257
  const color = yield* stdio.stdoutIsTerminal;
256
258
  const path = yield* Path.Path;
257
259
  // One write: separate Console.log calls have interleaved out of order here.
258
- yield* Console.log(render(result, { color, root: path.resolve() }).join("\n"));
260
+ yield* Console.log(
261
+ render(result, {
262
+ color,
263
+ root: path.resolve(),
264
+ threshold: plan.threshold,
265
+ sufficiencyThreshold: plan.sufficiencyThreshold,
266
+ }).join("\n"),
267
+ );
259
268
  // Only errors fail the run, unless --deny-warnings: a warning is for a nit or a noisy rule.
260
269
  const failed = failing(result.findings, input.denyWarnings);
261
270
  if (failed.length > 0) {
@@ -264,7 +273,7 @@ export const lintCommand = Command.make(
264
273
  }),
265
274
  ).pipe(
266
275
  Command.withDescription(
267
- "Audit the working directory against the rules in .adhere/ or a preset, judged by Jev.",
276
+ "Audit the working directory against the rules in .adhere/rules/ or a preset, judged by Jev.",
268
277
  ),
269
278
  Command.provide((input) =>
270
279
  auditLayer(
@@ -326,7 +335,7 @@ export const validateCommand = Command.make("validate", { preset }, () =>
326
335
  const installLine = (install: Install): string => {
327
336
  switch (install.status) {
328
337
  case "installed":
329
- return `installed: @drkmttr/adhere with ${install.packageManager}`;
338
+ return `installed: ${install.what} with ${install.packageManager}`;
330
339
  case "listed":
331
340
  return "installed: none, @drkmttr/adhere is already in package.json";
332
341
  case "no-package-json":
@@ -334,11 +343,33 @@ const installLine = (install: Install): string => {
334
343
  }
335
344
  };
336
345
 
346
+ const shared = Flag.String("shared").pipe(
347
+ Flag.filter(
348
+ (repo) => /^[\w.-]+\/[\w.-]+$/.test(repo),
349
+ (repo) => `--shared names a GitHub repo as org/repo, not ${repo}.`,
350
+ ),
351
+ Flag.optional,
352
+ Flag.withDescription(
353
+ "Scaffold a repo of rules other repos copy with adhere install, named org/repo on GitHub: example rules, a README, a CI workflow that runs adhere validate, and an alchemy.run.ts that creates the repo, public unless you make it private when asked, and its TYPESAFE_API_KEY secret.",
354
+ ),
355
+ );
356
+
337
357
  /** `adhere init`: the scaffold. */
338
- export const initCommand = Command.make("init", { force }, (input) =>
358
+ export const initCommand = Command.make("init", { force, shared }, (input) =>
339
359
  Effect.gen(function* () {
340
360
  const path = yield* Path.Path;
341
- const result = yield* initProject(path.resolve(), { force: input.force });
361
+ const stdio = yield* Stdio.Stdio;
362
+ // A shared repo is public unless the user, asked at a terminal, makes it private.
363
+ const interactive = (yield* stdio.stdinIsTerminal) && (yield* stdio.stdoutIsTerminal);
364
+ const makePrivate =
365
+ Option.isSome(input.shared) && interactive
366
+ ? yield* Prompt.run(Prompt.Confirm({ message: "Make the repo private?", initial: false }))
367
+ : false;
368
+ const result = yield* initProject(path.resolve(), {
369
+ force: input.force,
370
+ visibility: makePrivate ? "private" : "public",
371
+ ...Option.match(input.shared, { onNone: () => ({}), onSome: (repo) => ({ shared: repo }) }),
372
+ });
342
373
  const lines = [
343
374
  result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
344
375
  result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
@@ -352,6 +383,58 @@ export const initCommand = Command.make("init", { force }, (input) =>
352
383
  ),
353
384
  );
354
385
 
386
+ const source = Argument.String("source").pipe(
387
+ Argument.filterMap(
388
+ (spec) => Option.fromNullishOr(parseSource(spec)),
389
+ (spec) => `org/repo, then optionally a topic or rule and #ref, not ${spec}`,
390
+ ),
391
+ Argument.withDescription(
392
+ "A GitHub repo with rules in .adhere/rules/, as org/repo, optionally followed by a topic or a rule, as in org/repo/data/brand-ports, and by #branch or #tag.",
393
+ ),
394
+ );
395
+
396
+ /** `adhere list org/repo`: the rules another repo's `.adhere/rules/` holds. */
397
+ export const listCommand = Command.make("list", { source }, (input) =>
398
+ Effect.gen(function* () {
399
+ const rules = yield* listRules(input.source);
400
+ const width = Math.max(...rules.map(({ id }) => id.length));
401
+ yield* Console.log(
402
+ rules.map(({ id, description }) => `${id.padEnd(width)} ${description}`).join("\n"),
403
+ );
404
+ }),
405
+ ).pipe(
406
+ Command.withDescription(
407
+ "List the rules in a GitHub repo's .adhere/rules/, each with its description, for adhere install. Clones with git, so a private repo needs git's credentials for it.",
408
+ ),
409
+ );
410
+
411
+ /** `adhere install org/repo`: another repo's rules, copied into this one's `.adhere/rules/`. */
412
+ export const installCommand = Command.make(
413
+ "install",
414
+ {
415
+ source,
416
+ force: Flag.Boolean("force").pipe(
417
+ Flag.withDefault(false),
418
+ Flag.withDescription("Replace rules already in .adhere/rules/, directory and all."),
419
+ ),
420
+ },
421
+ (input) =>
422
+ Effect.gen(function* () {
423
+ const path = yield* Path.Path;
424
+ const result = yield* installRules(path.resolve(), input.source, { force: input.force });
425
+ yield* Console.log(
426
+ [
427
+ result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
428
+ result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
429
+ ].join("\n"),
430
+ );
431
+ }),
432
+ ).pipe(
433
+ Command.withDescription(
434
+ "Copy the rules in a GitHub repo's .adhere/rules/, or one topic or rule of them, each its whole directory, into this repo's .adhere/rules/<org>/<repo>/. They are this repo's own rules from then on, to edit or keep in step by hand, and a copied RULE.ts runs whenever lint does. A rule already there is skipped unless --force.",
435
+ ),
436
+ );
437
+
355
438
  /**
356
439
  * The key typed at a masked prompt, or piped in: `adhere login < key.txt`.
357
440
  * Whitespace around it, like the newline a pipe ends with, is dropped.
@@ -359,7 +442,7 @@ export const initCommand = Command.make("init", { force }, (input) =>
359
442
  const readApiKey = Effect.fn("login.readApiKey")(function* () {
360
443
  const stdio = yield* Stdio.Stdio;
361
444
  const typed: string = (yield* stdio.stdinIsTerminal)
362
- ? Redacted.value(yield* Prompt.run(Prompt.password({ message: "TypeSafe AI API key" })))
445
+ ? Redacted.value(yield* Prompt.run(Prompt.Password({ message: "TypeSafe AI API key" })))
363
446
  : yield* stdio.stdin.pipe(
364
447
  Stream.decodeText(),
365
448
  Stream.mkString,
@@ -410,27 +493,27 @@ export const logoutCommand = Command.make("logout", {}, () =>
410
493
  );
411
494
 
412
495
  /** The agent skills the binary carries, by the name `adhere skill` takes. */
413
- const skills = { rules: skill, fix: fixSkill } as const;
496
+ const skills = { docs: skill, setup: setupSkill, fix: fixSkill } as const;
414
497
 
415
- const skillName = Argument.choice("skill", ["rules", "fix"] as const).pipe(
416
- Argument.withDefault("rules" as const),
498
+ const skillName = Argument.Literals("skill", ["docs", "setup", "fix"] as const).pipe(
499
+ Argument.withDefault("docs" as const),
417
500
  Argument.withDescription(
418
- "rules, the default, to gather a repo's conventions into rules and configure adhere; fix, to verify and fix the findings lint reports.",
501
+ "docs, the default, the reference for rules, the config, and reports; setup, to set adhere up in a repo with the user, through its first lint and CI; fix, to verify and fix the findings lint reports.",
419
502
  ),
420
503
  );
421
504
 
422
- /** `adhere skill [rules|fix]`: an agent skill, as shipped in the binary. */
505
+ /** `adhere skill [docs|setup|fix]`: an agent skill, as shipped in the binary. */
423
506
  export const skillCommand = Command.make("skill", { name: skillName }, ({ name }) =>
424
507
  Console.log(skills[name].trimEnd()),
425
508
  ).pipe(
426
509
  Command.withDescription(
427
- "Print an agent skill: by default the one that gathers a repo's conventions into rules and configures adhere, or with fix, the one that verifies and fixes lint's findings. Pipe it into .agents/skills/<name>/SKILL.md or hand it to an agent.",
510
+ "Print an agent skill: by default adhere's reference, for writing rules, configuring adhere, and reading reports; with setup, the one that sets adhere up in a repo with the user, from choosing rules through the first lint to CI; with fix, the one that verifies and fixes lint's findings. Pipe it into .agents/skills/<name>/SKILL.md or hand it to an agent.",
428
511
  ),
429
512
  );
430
513
 
431
514
  /** The whole CLI. Bare `adhere` prints its help. */
432
515
  export const cli = Command.make("adhere").pipe(
433
- Command.withDescription("Lint a repository against the rules in .adhere/, judged by Jev."),
516
+ Command.withDescription("Lint a repository against the rules in .adhere/rules/, judged by Jev."),
434
517
  Command.withExamples([
435
518
  { command: "adhere init", description: "Scaffold .adhere/config.ts and two example rules" },
436
519
  {
@@ -439,6 +522,10 @@ export const cli = Command.make("adhere").pipe(
439
522
  },
440
523
  { command: "adhere login", description: "Save your TypeSafe AI API key for later runs" },
441
524
  { command: "adhere lint", description: "Audit the working directory" },
525
+ {
526
+ command: "adhere install org/repo",
527
+ description: "Copy another repo's .adhere/rules/ into this one",
528
+ },
442
529
  {
443
530
  command: "adhere lint --preset effect",
444
531
  description: "Audit against the built-in Effect rules",
@@ -448,6 +535,8 @@ export const cli = Command.make("adhere").pipe(
448
535
  lintCommand,
449
536
  validateCommand,
450
537
  initCommand,
538
+ listCommand,
539
+ installCommand,
451
540
  loginCommand,
452
541
  logoutCommand,
453
542
  skillCommand,
package/src/config.ts CHANGED
@@ -4,6 +4,34 @@ import { Effect, Schema, SchemaTransformation } from "effect";
4
4
 
5
5
  export type RuleId = string;
6
6
 
7
+ /** The state a request to Jev carries: the file's code as numbered sections, which every question reads. */
8
+ export interface JevState {
9
+ readonly code: Readonly<Record<string, string>>;
10
+ }
11
+
12
+ /** The file a request is about, as a rule's hook gets it: its absolute path, and its text as written. */
13
+ export interface JudgedFile {
14
+ readonly path: string;
15
+ readonly contents: string;
16
+ }
17
+
18
+ /**
19
+ * Bun's API, typed when the project has Bun's types. Read off `globalThis`,
20
+ * so this package's source type-checks in a project without them.
21
+ */
22
+ type BunApi = typeof globalThis extends { Bun: infer B } ? B : unknown;
23
+
24
+ /**
25
+ * A rule's hook on what Jev reads for it, run right before each request for
26
+ * the rule goes out: what it returns is spread over the request's state.
27
+ * Nothing it returns is checked, so it can replace `code` itself.
28
+ */
29
+ export type AppendState = (
30
+ state: JevState,
31
+ file: JudgedFile,
32
+ bun: BunApi,
33
+ ) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>;
34
+
7
35
  /**
8
36
  * A rule's examples, named in RFC 2119's words, which Jev reads as written. A
9
37
  * requirement shows code that `must` be written and code that must `never`
@@ -39,6 +67,16 @@ const RuleFields = Schema.Struct({
39
67
  * that breaks the rule is as it describes, and a yes to any drops the finding.
40
68
  */
41
69
  excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
70
+ /**
71
+ * A hook on the state Jev reads for the rule, in a rule written in
72
+ * TypeScript (see `AppendState`). The rule's questions go in requests of
73
+ * their own, so only they read what it adds.
74
+ */
75
+ appendState: Schema.optionalKey(
76
+ Schema.declare((value: unknown): value is AppendState => typeof value === "function", {
77
+ expected: "a function",
78
+ }),
79
+ ),
42
80
  });
43
81
 
44
82
  /** Before 0.7, a rule's examples were `reference` and `avoid`: read as `must` and `never`. */
@@ -137,8 +175,9 @@ export const AdhereConfig = Schema.Struct({
137
175
  Schema.withDecodingDefaultKey(Effect.succeed([])),
138
176
  ),
139
177
  /**
140
- * A record of rules, or a directory path relative to the config file.
141
- * Unset: `.adhere/` when that directory exists, otherwise no rules.
178
+ * A record of rules, or a directory path relative to the config file,
179
+ * which holds its rules as `.adhere/rules/` does. Unset: the rules in
180
+ * `.adhere/rules/`, and in any nested `.adhere/rules/`.
142
181
  */
143
182
  rules: Schema.optionalKey(Schema.Union([Schema.Record(Schema.String, Rule), Schema.String])),
144
183
  /**
@@ -161,8 +200,10 @@ export const AdhereConfig = Schema.Struct({
161
200
  includeComments: Schema.optionalKey(Schema.Boolean),
162
201
  });
163
202
 
164
- /** Where a repo keeps its rule files by default. The cache lives under it. */
203
+ /** Where a repo keeps what adhere reads and writes: its config, its rules, and the cache. */
165
204
  export const ADHERE_DIRECTORY = ".adhere";
205
+ /** Where a repo keeps its rules by default, one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`. */
206
+ export const RULES_DIRECTORY = `${ADHERE_DIRECTORY}/rules`;
166
207
  export const CACHE_DIRECTORY = `${ADHERE_DIRECTORY}/cache`;
167
208
  /** Where a config may live, relative to the working directory. Exactly one may exist. */
168
209
  export const CONFIG_FILES = [
@@ -210,6 +251,12 @@ export type Config = typeof AdhereConfig.Encoded;
210
251
 
211
252
  export const defineConfig = (config: Config) => config;
212
253
 
254
+ /**
255
+ * Types a rule written in TypeScript, which a `.adhere/` file default-exports,
256
+ * and returns it as it is. It takes what a config's inline rule does.
257
+ */
258
+ export const defineRule = (rule: typeof Rule.Encoded) => rule;
259
+
213
260
  /** A built-in rule set. Same shape as a config, minus `presets`. */
214
261
  export interface Preset {
215
262
  /**
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export {
2
2
  type AdhereConfig,
3
+ type AppendState,
3
4
  type Config,
4
5
  ConfigUnavailable,
5
6
  defineConfig,
7
+ defineRule,
6
8
  type Preset,
7
9
  type Rule,
8
10
  type RuleId,