@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.
- package/README.md +184 -37
- package/package.json +9 -9
- package/presets/effect/basics/{instrument-with-pipe.md → instrument-with-pipe/RULE.md} +1 -1
- package/presets/effect/config/{business-logic-depends-on-config-service.md → business-logic-depends-on-config-service/RULE.md} +3 -3
- package/presets/effect/config/{secrets-are-redacted.md → secrets-are-redacted/RULE.md} +3 -3
- package/presets/effect/config/{validate-with-schema.md → validate-with-schema/RULE.md} +4 -4
- package/skills/adhere/SKILL.md +206 -118
- package/skills/adhere-fix/SKILL.md +9 -8
- package/skills/adhere-setup/SKILL.md +244 -0
- package/src/cli.ts +114 -25
- package/src/config.ts +50 -3
- package/src/index.ts +2 -0
- package/src/init.ts +193 -27
- package/src/install.ts +158 -0
- package/src/main.ts +1 -1
- package/src/rules.ts +135 -56
- package/src/services/AdhereConfig.ts +2 -2
- package/src/services/Credentials.ts +4 -4
- package/src/services/Jev.http.ts +76 -10
- package/src/services/Jev.ts +39 -10
- package/src/workflows/audit.ts +23 -12
- package/src/workflows/format.ts +172 -35
- /package/presets/alchemy/apis/{match-remote-errors-by-tag.md → match-remote-errors-by-tag/RULE.md} +0 -0
- /package/presets/alchemy/apis/{schemas-at-trust-boundaries.md → schemas-at-trust-boundaries/RULE.md} +0 -0
- /package/presets/alchemy/data/{commit-migrations-then-deploy.md → commit-migrations-then-deploy/RULE.md} +0 -0
- /package/presets/alchemy/data/{hyperdrive-direct-origin.md → hyperdrive-direct-origin/RULE.md} +0 -0
- /package/presets/alchemy/data/{migrations-use-direct-url.md → migrations-use-direct-url/RULE.md} +0 -0
- /package/presets/alchemy/data/{never-disable-tls-verification.md → never-disable-tls-verification/RULE.md} +0 -0
- /package/presets/alchemy/data/{private-endpoint-at-runtime.md → private-endpoint-at-runtime/RULE.md} +0 -0
- /package/presets/alchemy/data/{single-migration-owner.md → single-migration-owner/RULE.md} +0 -0
- /package/presets/alchemy/durable/{idempotent-event-handlers.md → idempotent-event-handlers/RULE.md} +0 -0
- /package/presets/alchemy/durable/{io-inside-steps.md → io-inside-steps/RULE.md} +0 -0
- /package/presets/alchemy/durable/{no-self-triggering-bucket-writes.md → no-self-triggering-bucket-writes/RULE.md} +0 -0
- /package/presets/alchemy/durable/{stable-step-names.md → stable-step-names/RULE.md} +0 -0
- /package/presets/alchemy/durable/{webhook-state-is-truth.md → webhook-state-is-truth/RULE.md} +0 -0
- /package/presets/alchemy/providers/{diff-guards-unresolved-props.md → diff-guards-unresolved-props/RULE.md} +0 -0
- /package/presets/alchemy/providers/{idempotent-delete.md → idempotent-delete/RULE.md} +0 -0
- /package/presets/alchemy/providers/{lazy-layer-construction.md → lazy-layer-construction/RULE.md} +0 -0
- /package/presets/alchemy/providers/{reconcile-from-observed-state.md → reconcile-from-observed-state/RULE.md} +0 -0
- /package/presets/alchemy/resources/{anchor-paths-with-import-meta.md → anchor-paths-with-import-meta/RULE.md} +0 -0
- /package/presets/alchemy/resources/{idempotent-actions-and-seeds.md → idempotent-actions-and-seeds/RULE.md} +0 -0
- /package/presets/alchemy/resources/{previews-reference-shared-resources.md → previews-reference-shared-resources/RULE.md} +0 -0
- /package/presets/alchemy/resources/{protect-irreplaceable-data.md → protect-irreplaceable-data/RULE.md} +0 -0
- /package/presets/alchemy/resources/{remote-state-for-ci.md → remote-state-for-ci/RULE.md} +0 -0
- /package/presets/alchemy/resources/{stable-logical-ids.md → stable-logical-ids/RULE.md} +0 -0
- /package/presets/alchemy/resources/{stage-unique-physical-names.md → stage-unique-physical-names/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{bind-tag-not-implementation.md → bind-tag-not-implementation/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{deterministic-config-defaults.md → deterministic-config-defaults/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{disposables-in-request-scope.md → disposables-in-request-scope/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{lambda-no-post-response-work.md → lambda-no-post-response-work/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{no-per-request-work-at-construction.md → no-per-request-work-at-construction/RULE.md} +0 -0
- /package/presets/alchemy/runtime/{resolve-config-and-bindings-at-construction.md → resolve-config-and-bindings-at-construction/RULE.md} +0 -0
- /package/presets/alchemy/secrets/{let-alchemy-mint-infra-secrets.md → let-alchemy-mint-infra-secrets/RULE.md} +0 -0
- /package/presets/alchemy/secrets/{none-in-browser-env.md → none-in-browser-env/RULE.md} +0 -0
- /package/presets/alchemy/secrets/{redacted-until-use.md → redacted-until-use/RULE.md} +0 -0
- /package/presets/alchemy/secrets/{unwrap-before-compare-or-send.md → unwrap-before-compare-or-send/RULE.md} +0 -0
- /package/presets/alchemy/security/{authorize-public-endpoints.md → authorize-public-endpoints/RULE.md} +0 -0
- /package/presets/alchemy/security/{github-secret-not-variable.md → github-secret-not-variable/RULE.md} +0 -0
- /package/presets/alchemy/security/{identity-from-verified-session.md → identity-from-verified-session/RULE.md} +0 -0
- /package/presets/alchemy/security/{keep-auth-protections-on.md → keep-auth-protections-on/RULE.md} +0 -0
- /package/presets/alchemy/security/{least-privilege-bindings.md → least-privilege-bindings/RULE.md} +0 -0
- /package/presets/alchemy/security/{least-privilege-ci-credentials.md → least-privilege-ci-credentials/RULE.md} +0 -0
- /package/presets/alchemy/security/{lookup-failure-is-not-anonymous.md → lookup-failure-is-not-anonymous/RULE.md} +0 -0
- /package/presets/effect/basics/{gen-for-sequencing.md → gen-for-sequencing/RULE.md} +0 -0
- /package/presets/effect/config/{tests-provide-values-directly.md → tests-provide-values-directly/RULE.md} +0 -0
- /package/presets/effect/data/{brand-meaningful-primitives.md → brand-meaningful-primitives/RULE.md} +0 -0
- /package/presets/effect/data/{variants-are-tagged-unions.md → variants-are-tagged-unions/RULE.md} +0 -0
- /package/presets/effect/errors/{catch-defects-at-boundaries-only.md → catch-defects-at-boundaries-only/RULE.md} +0 -0
- /package/presets/effect/errors/{defects-for-bugs.md → defects-for-bugs/RULE.md} +0 -0
- /package/presets/effect/errors/{wrap-external-errors.md → wrap-external-errors/RULE.md} +0 -0
- /package/presets/effect/services/{dependencies-through-layers.md → dependencies-through-layers/RULE.md} +0 -0
- /package/presets/effect/services/{fresh-layer-per-test.md → fresh-layer-per-test/RULE.md} +0 -0
- /package/presets/effect/services/{memoize-parameterized-layers.md → memoize-parameterized-layers/RULE.md} +0 -0
- /package/presets/effect/services/{no-mutable-state.md → no-mutable-state/RULE.md} +0 -0
- /package/presets/effect/services/{operations-have-no-requirements.md → operations-have-no-requirements/RULE.md} +0 -0
- /package/presets/effect/services/{test-layers-are-in-memory.md → test-layers-are-in-memory/RULE.md} +0 -0
- /package/presets/effect/testing/{test-clock-for-time.md → test-clock-for-time/RULE.md} +0 -0
- /package/presets/react/effects/{clean-up-what-they-start.md → clean-up-what-they-start/RULE.md} +0 -0
- /package/presets/react/effects/{event-logic-in-handlers.md → event-logic-in-handlers/RULE.md} +0 -0
- /package/presets/react/effects/{external-stores-use-sync-external-store.md → external-stores-use-sync-external-store/RULE.md} +0 -0
- /package/presets/react/effects/{fetches-ignore-stale-responses.md → fetches-ignore-stale-responses/RULE.md} +0 -0
- /package/presets/react/effects/{notify-parents-in-handlers.md → notify-parents-in-handlers/RULE.md} +0 -0
- /package/presets/react/server/{no-private-data-to-client.md → no-private-data-to-client/RULE.md} +0 -0
- /package/presets/react/server/{server-functions-authorize-and-validate.md → server-functions-authorize-and-validate/RULE.md} +0 -0
- /package/presets/react/state/{no-contradictory-state.md → no-contradictory-state/RULE.md} +0 -0
- /package/presets/react/state/{no-props-copied-into-state.md → no-props-copied-into-state/RULE.md} +0 -0
- /package/presets/react/state/{store-ids-not-copies.md → store-ids-not-copies/RULE.md} +0 -0
- /package/presets/security/{constant-time-secret-compare.md → constant-time-secret-compare/RULE.md} +0 -0
- /package/presets/security/{no-secrets-in-output.md → no-secrets-in-output/RULE.md} +0 -0
- /package/presets/security/{no-untrusted-input-in-commands-or-paths.md → no-untrusted-input-in-commands-or-paths/RULE.md} +0 -0
- /package/presets/security/{parameterized-queries.md → parameterized-queries/RULE.md} +0 -0
- /package/presets/typescript/async/{concurrent-independent-work.md → concurrent-independent-work/RULE.md} +0 -0
- /package/presets/typescript/async/{network-calls-have-timeouts.md → network-calls-have-timeouts/RULE.md} +0 -0
- /package/presets/typescript/design/{no-argument-mutation.md → no-argument-mutation/RULE.md} +0 -0
- /package/presets/typescript/errors/{no-swallowed-errors.md → no-swallowed-errors/RULE.md} +0 -0
- /package/presets/typescript/errors/{release-on-every-path.md → release-on-every-path/RULE.md} +0 -0
- /package/presets/typescript/testing/{assert-outcomes.md → assert-outcomes/RULE.md} +0 -0
- /package/presets/typescript/testing/{independent-tests.md → independent-tests/RULE.md} +0 -0
- /package/presets/typescript/types/{assertions-only-when-proven.md → assertions-only-when-proven/RULE.md} +0 -0
- /package/presets/typescript/types/{derive-dont-restate.md → derive-dont-restate/RULE.md} +0 -0
- /package/presets/typescript/types/{unrepresentable-invalid-states.md → unrepresentable-invalid-states/RULE.md} +0 -0
- /package/presets/typescript/types/{validate-external-data.md → validate-external-data/RULE.md} +0 -0
package/src/init.ts
CHANGED
|
@@ -12,15 +12,29 @@ export interface InitResult {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export type Install =
|
|
15
|
-
| {
|
|
15
|
+
| {
|
|
16
|
+
readonly status: "installed";
|
|
17
|
+
/** @drkmttr/adhere, added, or in a shared repo, package.json, installed. */
|
|
18
|
+
readonly what: string;
|
|
19
|
+
readonly packageManager: PackageManager;
|
|
20
|
+
}
|
|
16
21
|
| { readonly status: "listed" }
|
|
17
22
|
| { readonly status: "no-package-json" };
|
|
18
23
|
|
|
19
24
|
export interface InitOptions {
|
|
20
25
|
readonly force?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* `org/repo`: scaffold a repo whose rules other repos copy with `adhere
|
|
28
|
+
* install`, rather than one that lints itself.
|
|
29
|
+
*/
|
|
30
|
+
readonly shared?: string;
|
|
31
|
+
/** Who can see the shared repo alchemy creates. Defaults to public. */
|
|
32
|
+
readonly visibility?: Visibility;
|
|
21
33
|
}
|
|
22
34
|
|
|
23
|
-
|
|
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.
|
|
24
38
|
const CONFIG = `import { defineConfig } from "@drkmttr/adhere";
|
|
25
39
|
|
|
26
40
|
export default defineConfig({
|
|
@@ -29,10 +43,11 @@ export default defineConfig({
|
|
|
29
43
|
`;
|
|
30
44
|
|
|
31
45
|
/**
|
|
32
|
-
* A project for the config
|
|
33
|
-
* without it the editor opens
|
|
34
|
-
*
|
|
35
|
-
* and `nodenext`
|
|
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.
|
|
36
51
|
*/
|
|
37
52
|
const TSCONFIG = `{
|
|
38
53
|
"compilerOptions": {
|
|
@@ -44,7 +59,7 @@ const TSCONFIG = `{
|
|
|
44
59
|
"skipLibCheck": true,
|
|
45
60
|
"allowImportingTsExtensions": true
|
|
46
61
|
},
|
|
47
|
-
"include": ["
|
|
62
|
+
"include": ["**/*.ts"]
|
|
48
63
|
}
|
|
49
64
|
`;
|
|
50
65
|
|
|
@@ -100,18 +115,164 @@ export const handle = (id: string) =>
|
|
|
100
115
|
\`\`\`
|
|
101
116
|
`;
|
|
102
117
|
|
|
118
|
+
const sharedReadme = (org: string, repo: string) => `# ${repo}
|
|
119
|
+
|
|
120
|
+
Shared [adhere](https://github.com/darkmatter/adhere) rules for ${org}'s
|
|
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\`.
|
|
124
|
+
|
|
125
|
+
## Use these rules
|
|
126
|
+
|
|
127
|
+
List the rules, then copy them into a repo, all of them or one topic or rule:
|
|
128
|
+
|
|
129
|
+
\`\`\`sh
|
|
130
|
+
adhere list ${org}/${repo}
|
|
131
|
+
adhere install ${org}/${repo}
|
|
132
|
+
adhere install ${org}/${repo}/style
|
|
133
|
+
adhere install ${org}/${repo}/style/prefer-small-files
|
|
134
|
+
\`\`\`
|
|
135
|
+
|
|
136
|
+
The copies land in \`.adhere/rules/${org}/${repo}/\` and are that repo's own from then
|
|
137
|
+
on, to edit or to start other rules from. To take a later version of a rule,
|
|
138
|
+
install it again with \`--force\`, which overwrites local edits.
|
|
139
|
+
|
|
140
|
+
## Add a rule
|
|
141
|
+
|
|
142
|
+
Add a directory under \`.adhere/rules/<topic>/\` with the rule in a \`RULE.md\`;
|
|
143
|
+
its path is the rule's id. See
|
|
144
|
+
[Rules as Markdown files](https://github.com/darkmatter/adhere#rules-as-markdown-files)
|
|
145
|
+
and the [rule writing tips](https://github.com/darkmatter/adhere#rule-writing-tips).
|
|
146
|
+
\`adhere validate\` checks each rule's wording and asks Jev whether any two
|
|
147
|
+
contradict. CI runs it on every push to main and every pull request.
|
|
148
|
+
|
|
149
|
+
## Setup
|
|
150
|
+
|
|
151
|
+
\`alchemy.run.ts\` creates this repository on GitHub, or adopts it when it
|
|
152
|
+
exists, and sets the \`TYPESAFE_API_KEY\` secret CI's \`adhere validate\` needs.
|
|
153
|
+
Install, which init already did, deploy once, then push:
|
|
154
|
+
|
|
155
|
+
\`\`\`sh
|
|
156
|
+
npm install
|
|
157
|
+
TYPESAFE_API_KEY=... npx alchemy deploy
|
|
158
|
+
git init && git add -A && git commit -m "Add shared adhere rules"
|
|
159
|
+
git remote add origin git@github.com:${org}/${repo}.git && git push -u origin main
|
|
160
|
+
\`\`\`
|
|
161
|
+
|
|
162
|
+
Deploying again updates the secret, as after rotating the key.
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
const SHARED_WORKFLOW = `name: adhere
|
|
166
|
+
|
|
167
|
+
on:
|
|
168
|
+
pull_request:
|
|
169
|
+
push:
|
|
170
|
+
branches: [main]
|
|
171
|
+
|
|
172
|
+
jobs:
|
|
173
|
+
validate:
|
|
174
|
+
runs-on: ubuntu-latest
|
|
175
|
+
steps:
|
|
176
|
+
- uses: actions/checkout@v4
|
|
177
|
+
- run: npx --yes @drkmttr/adhere validate
|
|
178
|
+
env:
|
|
179
|
+
TYPESAFE_API_KEY: \${{ secrets.TYPESAFE_API_KEY }}
|
|
180
|
+
`;
|
|
181
|
+
|
|
182
|
+
const sharedStack = (
|
|
183
|
+
org: string,
|
|
184
|
+
repo: string,
|
|
185
|
+
visibility: Visibility,
|
|
186
|
+
) => `import * as Alchemy from "alchemy";
|
|
187
|
+
import * as GitHub from "alchemy/GitHub";
|
|
188
|
+
import * as Output from "alchemy/Output";
|
|
189
|
+
import * as Config from "effect/Config";
|
|
190
|
+
import * as Effect from "effect/Effect";
|
|
191
|
+
|
|
192
|
+
const owner = "${org}";
|
|
193
|
+
|
|
194
|
+
export default Alchemy.Stack(
|
|
195
|
+
"${repo}",
|
|
196
|
+
{ providers: GitHub.providers(), state: Alchemy.localState() },
|
|
197
|
+
Effect.gen(function* () {
|
|
198
|
+
const repository = yield* GitHub.Repository("rules", {
|
|
199
|
+
owner,
|
|
200
|
+
name: "${repo}",
|
|
201
|
+
description: "Shared adhere rules",
|
|
202
|
+
visibility: "${visibility}",
|
|
203
|
+
});
|
|
204
|
+
// CI's adhere validate asks Jev whether any two rules contradict. Naming
|
|
205
|
+
// the repository by its output makes alchemy create it before the secret.
|
|
206
|
+
yield* GitHub.Secret("typesafe-api-key", {
|
|
207
|
+
owner,
|
|
208
|
+
repository: Output.map(repository.fullName, (fullName) => fullName.split("/")[1] ?? ""),
|
|
209
|
+
name: "TYPESAFE_API_KEY",
|
|
210
|
+
value: yield* Config.Redacted("TYPESAFE_API_KEY"),
|
|
211
|
+
});
|
|
212
|
+
return { url: repository.htmlUrl };
|
|
213
|
+
}).pipe(Effect.orDie),
|
|
214
|
+
);
|
|
215
|
+
`;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* alchemy's CLI runs on effect and its Node platform. They are pinned, as is
|
|
219
|
+
* the platform's own dependency, since a range of prereleases takes the newest:
|
|
220
|
+
* effect 4.0.0-rc.118 moved modules alchemy 2.0.0-beta.79 imports (checked
|
|
221
|
+
* 2026-09-28).
|
|
222
|
+
*/
|
|
223
|
+
const sharedPackage = (repo: string) =>
|
|
224
|
+
`${JSON.stringify(
|
|
225
|
+
{
|
|
226
|
+
name: repo,
|
|
227
|
+
private: true,
|
|
228
|
+
type: "module",
|
|
229
|
+
devDependencies: {
|
|
230
|
+
"@effect/platform-node": "4.0.0-rc.117",
|
|
231
|
+
alchemy: "2.0.0-beta.79",
|
|
232
|
+
effect: "4.0.0-rc.117",
|
|
233
|
+
},
|
|
234
|
+
overrides: { "@effect/platform-node-shared": "4.0.0-rc.117" },
|
|
235
|
+
},
|
|
236
|
+
null,
|
|
237
|
+
2,
|
|
238
|
+
)}\n`;
|
|
239
|
+
|
|
240
|
+
const SHARED_GITIGNORE = `node_modules/
|
|
241
|
+
.alchemy/
|
|
242
|
+
`;
|
|
243
|
+
|
|
103
244
|
const CONFIG_PATH = CONFIG_FILES[0];
|
|
104
245
|
|
|
105
|
-
/** The
|
|
246
|
+
/** The project of the config at `.adhere/config.ts`, and of the rules written in TypeScript beside it. */
|
|
106
247
|
const CONFIG_PROJECT = ".adhere/tsconfig.json";
|
|
107
248
|
|
|
108
|
-
const
|
|
109
|
-
[
|
|
110
|
-
[
|
|
111
|
-
[".adhere/style/prefer-small-files.md", SMALL_FILES],
|
|
112
|
-
[".adhere/style/name-domain-actions.md", NAME_EFFECTS],
|
|
249
|
+
const EXAMPLES = [
|
|
250
|
+
[".adhere/rules/style/prefer-small-files/RULE.md", SMALL_FILES],
|
|
251
|
+
[".adhere/rules/style/name-domain-actions/RULE.md", NAME_EFFECTS],
|
|
113
252
|
] as const;
|
|
114
253
|
|
|
254
|
+
/**
|
|
255
|
+
* A shared repo has no config: \`adhere install\` copies only rules, and
|
|
256
|
+
* \`validate\` reads \`.adhere/rules/\` without one.
|
|
257
|
+
*/
|
|
258
|
+
const filesFor = (
|
|
259
|
+
shared: string | undefined,
|
|
260
|
+
visibility: Visibility,
|
|
261
|
+
): ReadonlyArray<readonly [string, string]> => {
|
|
262
|
+
if (shared === undefined) {
|
|
263
|
+
return [[CONFIG_PATH, CONFIG], [CONFIG_PROJECT, TSCONFIG], ...EXAMPLES];
|
|
264
|
+
}
|
|
265
|
+
const [org = "", repo = ""] = shared.split("/");
|
|
266
|
+
return [
|
|
267
|
+
["README.md", sharedReadme(org, repo)],
|
|
268
|
+
[".github/workflows/adhere.yaml", SHARED_WORKFLOW],
|
|
269
|
+
["alchemy.run.ts", sharedStack(org, repo, visibility)],
|
|
270
|
+
["package.json", sharedPackage(repo)],
|
|
271
|
+
[".gitignore", SHARED_GITIGNORE],
|
|
272
|
+
...EXAMPLES,
|
|
273
|
+
];
|
|
274
|
+
};
|
|
275
|
+
|
|
115
276
|
const exists = (path: string): Promise<boolean> =>
|
|
116
277
|
access(path).then(
|
|
117
278
|
() => true,
|
|
@@ -165,31 +326,36 @@ const packageManagerAt = async (root: string): Promise<PackageManager> => {
|
|
|
165
326
|
|
|
166
327
|
/**
|
|
167
328
|
* pnpm refuses to add to a workspace root without a flag. `-w` fails outside a
|
|
168
|
-
* workspace, so
|
|
169
|
-
* also fails on msgpackr-extract's unapproved build script. It comes with
|
|
170
|
-
* effect, and adhere never calls msgpackr, so the second flag lets pnpm skip
|
|
171
|
-
* the build with a warning.
|
|
329
|
+
* workspace, so this one, which works in both, stands in for it.
|
|
172
330
|
*/
|
|
173
331
|
const addFlags: Record<PackageManager, ReadonlyArray<string>> = {
|
|
174
332
|
bun: [],
|
|
175
|
-
pnpm: ["--ignore-workspace-root-check"
|
|
333
|
+
pnpm: ["--ignore-workspace-root-check"],
|
|
176
334
|
yarn: [],
|
|
177
335
|
npm: [],
|
|
178
336
|
};
|
|
179
337
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
338
|
+
/**
|
|
339
|
+
* Adds adhere to devDependencies, so the config's `import type` resolves. A
|
|
340
|
+
* shared repo's package.json, which init writes, lists what it needs already,
|
|
341
|
+
* so there it installs that instead.
|
|
342
|
+
*/
|
|
343
|
+
const installPackages = (root: string, shared: boolean) =>
|
|
182
344
|
Effect.tryPromise({
|
|
183
345
|
try: async (): Promise<Install> => {
|
|
184
346
|
const manifest = await readFile(join(root, "package.json"), "utf8").catch(() => undefined);
|
|
185
347
|
if (manifest === undefined) return { status: "no-package-json" };
|
|
186
348
|
const { dependencies, devDependencies } = JSON.parse(manifest);
|
|
187
|
-
if (
|
|
349
|
+
if (
|
|
350
|
+
!shared &&
|
|
351
|
+
(dependencies?.[PACKAGE] !== undefined || devDependencies?.[PACKAGE] !== undefined)
|
|
352
|
+
) {
|
|
188
353
|
return { status: "listed" };
|
|
189
354
|
}
|
|
190
355
|
const packageManager = await packageManagerAt(root);
|
|
356
|
+
const args = shared ? ["install"] : ["add", "-D", PACKAGE, ...addFlags[packageManager]];
|
|
191
357
|
await new Promise<void>((resolve, reject) => {
|
|
192
|
-
spawn(packageManager,
|
|
358
|
+
spawn(packageManager, args, {
|
|
193
359
|
cwd: root,
|
|
194
360
|
stdio: "inherit",
|
|
195
361
|
})
|
|
@@ -198,11 +364,11 @@ const installAdhere = (root: string) =>
|
|
|
198
364
|
code === 0 ? resolve() : reject(new Error(`${packageManager} exited with ${code}`)),
|
|
199
365
|
);
|
|
200
366
|
});
|
|
201
|
-
return { status: "installed", packageManager };
|
|
367
|
+
return { status: "installed", what: shared ? "package.json" : PACKAGE, packageManager };
|
|
202
368
|
},
|
|
203
369
|
catch: (cause) =>
|
|
204
370
|
new Error(
|
|
205
|
-
`Could not
|
|
371
|
+
`Could not install ${shared ? "package.json" : PACKAGE}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
206
372
|
),
|
|
207
373
|
});
|
|
208
374
|
|
|
@@ -213,8 +379,8 @@ export const initProject = (
|
|
|
213
379
|
Effect.gen(function* () {
|
|
214
380
|
const created: Array<string> = [];
|
|
215
381
|
const skipped: Array<string> = [];
|
|
216
|
-
const existing = yield* otherConfig(root);
|
|
217
|
-
for (const [path, contents] of
|
|
382
|
+
const existing = options.shared === undefined ? yield* otherConfig(root) : undefined;
|
|
383
|
+
for (const [path, contents] of filesFor(options.shared, options.visibility ?? "public")) {
|
|
218
384
|
if (path === CONFIG_PATH && existing !== undefined) {
|
|
219
385
|
skipped.push(existing);
|
|
220
386
|
continue;
|
|
@@ -228,6 +394,6 @@ export const initProject = (
|
|
|
228
394
|
skipped.push(relative(root, join(root, path)));
|
|
229
395
|
}
|
|
230
396
|
}
|
|
231
|
-
const install = yield*
|
|
397
|
+
const install = yield* installPackages(root, options.shared !== undefined);
|
|
232
398
|
return { created, skipped, install };
|
|
233
399
|
});
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, cp, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, sep } from "node:path";
|
|
5
|
+
import { RULES_DIRECTORY, type RuleId } from "#config.ts";
|
|
6
|
+
import { parseRuleMarkdown } from "#markdown.ts";
|
|
7
|
+
import { Effect } from "effect";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A GitHub repo whose `.adhere/rules/` another repo copies: `org/repo`, then
|
|
11
|
+
* optionally a topic or a rule's id, then optionally `#` and a branch or tag,
|
|
12
|
+
* as in `darkmatter/standards/data/brand-ports#v3`.
|
|
13
|
+
*/
|
|
14
|
+
export interface Source {
|
|
15
|
+
readonly repo: string;
|
|
16
|
+
/** A rule id or a topic, `data/brand-ports` or `data`; empty for every rule. */
|
|
17
|
+
readonly rule: string;
|
|
18
|
+
readonly ref?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const parseSource = (spec: string): Source | undefined => {
|
|
22
|
+
const [path = "", ref] = spec.split("#");
|
|
23
|
+
const [org, repo, ...rule] = path.split("/").filter((segment) => segment.length > 0);
|
|
24
|
+
if (org === undefined || repo === undefined || ref === "") return undefined;
|
|
25
|
+
return { repo: `${org}/${repo}`, rule: rule.join("/"), ...(ref === undefined ? {} : { ref }) };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const failed = (what: string) => (cause: unknown) =>
|
|
29
|
+
new Error(`${what}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A shallow clone in a temporary directory, deleted when the scope closes.
|
|
33
|
+
* git, rather than GitHub's API, so a private repo is reached with whatever
|
|
34
|
+
* credentials git already has, and `url.<base>.insteadOf` can point
|
|
35
|
+
* github.com elsewhere, such as at SSH.
|
|
36
|
+
*/
|
|
37
|
+
const cloned = (source: Source) =>
|
|
38
|
+
Effect.acquireRelease(
|
|
39
|
+
Effect.tryPromise({
|
|
40
|
+
try: async () => {
|
|
41
|
+
const directory = await mkdtemp(join(tmpdir(), "adhere-install-"));
|
|
42
|
+
const branch = source.ref === undefined ? [] : ["--branch", source.ref];
|
|
43
|
+
const url = `https://github.com/${source.repo}.git`;
|
|
44
|
+
await new Promise<void>((resolve, reject) => {
|
|
45
|
+
let stderr = "";
|
|
46
|
+
spawn("git", ["clone", "--quiet", "--depth", "1", ...branch, url, directory], {
|
|
47
|
+
stdio: ["inherit", "ignore", "pipe"],
|
|
48
|
+
})
|
|
49
|
+
.on("error", reject)
|
|
50
|
+
.on("exit", (code) =>
|
|
51
|
+
code === 0
|
|
52
|
+
? resolve()
|
|
53
|
+
: reject(new Error(stderr.trim() || `git exited with ${code}`)),
|
|
54
|
+
)
|
|
55
|
+
.stderr?.on("data", (chunk) => {
|
|
56
|
+
stderr += chunk;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return directory;
|
|
60
|
+
},
|
|
61
|
+
catch: failed(`Could not clone ${source.repo}`),
|
|
62
|
+
}),
|
|
63
|
+
(directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
|
|
64
|
+
);
|
|
65
|
+
|
|
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
|
+
*/
|
|
70
|
+
const rulesIn = (source: Source, clone: string) =>
|
|
71
|
+
Effect.tryPromise({
|
|
72
|
+
try: async () => {
|
|
73
|
+
const directory = join(clone, RULES_DIRECTORY);
|
|
74
|
+
const entries = await readdir(directory, { recursive: true }).catch(() => []);
|
|
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
|
+
})
|
|
80
|
+
.filter(
|
|
81
|
+
({ id }) => source.rule === "" || id === source.rule || id.startsWith(`${source.rule}/`),
|
|
82
|
+
)
|
|
83
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
84
|
+
if (rules.length === 0) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
source.rule === ""
|
|
87
|
+
? `it has no rules in ${RULES_DIRECTORY}/`
|
|
88
|
+
: `it has no rule or topic ${source.rule} in ${RULES_DIRECTORY}/`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return rules.map(({ id, name }) => ({ id, name, directory: join(directory, id) }));
|
|
92
|
+
},
|
|
93
|
+
catch: failed(`Nothing to copy from ${source.repo}`),
|
|
94
|
+
});
|
|
95
|
+
|
|
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
|
+
*/
|
|
100
|
+
export const listRules = (source: Source) =>
|
|
101
|
+
Effect.scoped(
|
|
102
|
+
Effect.gen(function* () {
|
|
103
|
+
const rules = yield* rulesIn(source, yield* cloned(source));
|
|
104
|
+
return yield* Effect.forEach(rules, ({ id, name, directory }) =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
if (name === "RULE.ts") return { id, description: "(TypeScript, which list does not run)" };
|
|
107
|
+
const text = yield* Effect.tryPromise({
|
|
108
|
+
try: () => readFile(join(directory, name), "utf8"),
|
|
109
|
+
catch: failed(`Could not read ${id}`),
|
|
110
|
+
});
|
|
111
|
+
const rule = yield* parseRuleMarkdown(text, `${source.repo}/${id}/${name}`);
|
|
112
|
+
return { id, description: rule.description };
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
export interface InstallResult {
|
|
119
|
+
readonly created: ReadonlyArray<string>;
|
|
120
|
+
readonly skipped: ReadonlyArray<string>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
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.
|
|
130
|
+
*/
|
|
131
|
+
export const installRules = (root: string, source: Source, options: { readonly force?: boolean }) =>
|
|
132
|
+
Effect.scoped(
|
|
133
|
+
Effect.gen(function* () {
|
|
134
|
+
const rules = yield* rulesIn(source, yield* cloned(source));
|
|
135
|
+
const created: Array<string> = [];
|
|
136
|
+
const skipped: Array<string> = [];
|
|
137
|
+
for (const { id, directory } of rules) {
|
|
138
|
+
const path = `${RULES_DIRECTORY}/${source.repo}/${id}/`;
|
|
139
|
+
const target = join(root, path);
|
|
140
|
+
const copied = yield* Effect.tryPromise({
|
|
141
|
+
try: async () => {
|
|
142
|
+
const exists = await access(target).then(
|
|
143
|
+
() => true,
|
|
144
|
+
() => false,
|
|
145
|
+
);
|
|
146
|
+
if (exists && options.force !== true) return false;
|
|
147
|
+
await rm(target, { recursive: true, force: true });
|
|
148
|
+
await mkdir(dirname(target), { recursive: true });
|
|
149
|
+
await cp(directory, target, { recursive: true });
|
|
150
|
+
return true;
|
|
151
|
+
},
|
|
152
|
+
catch: failed(`Could not write ${path}`),
|
|
153
|
+
});
|
|
154
|
+
(copied ? created : skipped).push(path);
|
|
155
|
+
}
|
|
156
|
+
return { created, skipped } satisfies InstallResult;
|
|
157
|
+
}),
|
|
158
|
+
);
|
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/
|
|
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
|