@drkmttr/adhere 0.9.5 → 0.10.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 +245 -65
- package/package.json +6 -6
- package/presets/alchemy/apis/match-remote-errors-by-tag.md +6 -2
- package/presets/alchemy/apis/schemas-at-trust-boundaries.md +6 -2
- package/presets/alchemy/data/commit-migrations-then-deploy.md +6 -2
- package/presets/alchemy/data/hyperdrive-direct-origin.md +6 -2
- package/presets/alchemy/data/migrations-use-direct-url.md +6 -2
- package/presets/alchemy/data/never-disable-tls-verification.md +6 -2
- package/presets/alchemy/data/parameterized-sql.md +6 -2
- package/presets/alchemy/data/private-endpoint-at-runtime.md +6 -2
- package/presets/alchemy/data/single-migration-owner.md +6 -2
- package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
- package/presets/alchemy/durable/io-inside-steps.md +6 -2
- package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
- package/presets/alchemy/durable/stable-step-names.md +6 -2
- package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
- package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
- package/presets/alchemy/providers/idempotent-delete.md +8 -3
- package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
- package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
- package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
- package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
- package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
- package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
- package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
- package/presets/alchemy/resources/stable-logical-ids.md +6 -2
- package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
- package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
- package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
- package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
- package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
- package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
- package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
- package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +6 -2
- package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
- package/presets/alchemy/secrets/redacted-until-use.md +6 -2
- package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
- package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
- package/presets/alchemy/security/github-secret-not-variable.md +6 -2
- package/presets/alchemy/security/identity-from-verified-session.md +6 -2
- package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
- package/presets/alchemy/security/least-privilege-bindings.md +6 -2
- package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
- package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
- package/presets/effect/basics/external-calls-are-resilient.md +6 -2
- package/presets/effect/basics/gen-for-sequencing.md +6 -2
- package/presets/effect/basics/instrument-with-pipe.md +6 -2
- package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
- package/presets/effect/config/secrets-are-redacted.md +6 -2
- package/presets/effect/config/tests-provide-values-directly.md +7 -2
- package/presets/effect/config/validate-with-schema.md +6 -2
- package/presets/effect/data/brand-meaningful-primitives.md +6 -2
- package/presets/effect/data/variants-are-tagged-unions.md +6 -2
- package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
- package/presets/effect/errors/defects-for-bugs.md +6 -2
- package/presets/effect/errors/wrap-external-errors.md +6 -2
- package/presets/effect/services/dependencies-through-layers.md +6 -2
- package/presets/effect/services/fresh-layer-per-test.md +7 -2
- package/presets/effect/services/memoize-parameterized-layers.md +6 -2
- package/presets/effect/services/no-mutable-state.md +6 -2
- package/presets/effect/services/operations-have-no-requirements.md +6 -2
- package/presets/effect/services/test-layers-are-in-memory.md +7 -2
- package/presets/effect/testing/test-clock-for-time.md +7 -2
- package/skills/adhere/SKILL.md +40 -12
- package/skills/adhere-fix/SKILL.md +174 -0
- package/src/cli.ts +51 -14
- package/src/comments.ts +36 -0
- package/src/config.ts +98 -14
- package/src/contradictions.ts +10 -2
- package/src/excerpt.ts +11 -1
- package/src/index.ts +1 -1
- package/src/init.ts +119 -12
- package/src/main.ts +0 -1
- package/src/markdown.ts +77 -14
- package/src/models/Audit.ts +2 -0
- package/src/presets.ts +6 -3
- package/src/rules.ts +35 -2
- package/src/services/AdhereConfig.ts +36 -13
- package/src/services/SourceWalker.ts +32 -10
- package/src/suppress.ts +113 -0
- package/src/workflows/audit.ts +99 -17
- package/src/workflows/format.ts +20 -13
package/src/init.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { dirname, join, relative } from "node:path";
|
|
3
4
|
import { CONFIG_FILES } from "#config.ts";
|
|
4
5
|
import { Effect } from "effect";
|
|
@@ -6,17 +7,45 @@ import { Effect } from "effect";
|
|
|
6
7
|
export interface InitResult {
|
|
7
8
|
readonly created: ReadonlyArray<string>;
|
|
8
9
|
readonly skipped: ReadonlyArray<string>;
|
|
10
|
+
/** The package manager that added adhere, or why none did. */
|
|
11
|
+
readonly install: Install;
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
export type Install =
|
|
15
|
+
| { readonly status: "installed"; readonly packageManager: PackageManager }
|
|
16
|
+
| { readonly status: "listed" }
|
|
17
|
+
| { readonly status: "no-package-json" };
|
|
18
|
+
|
|
11
19
|
export interface InitOptions {
|
|
12
20
|
readonly force?: boolean;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
// 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.
|
|
24
|
+
const CONFIG = `import { defineConfig } from "@drkmttr/adhere";
|
|
16
25
|
|
|
17
|
-
export default {
|
|
26
|
+
export default defineConfig({
|
|
18
27
|
threshold: 0.8,
|
|
19
|
-
}
|
|
28
|
+
});
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A project for the config alone. A tsconfig's globs skip dot directories, so
|
|
33
|
+
* without it the editor opens `.adhere/config.ts` outside any project, where
|
|
34
|
+
* it cannot resolve `@drkmttr/adhere`, whose types only `bundler`, `node16`,
|
|
35
|
+
* and `nodenext` resolution reach.
|
|
36
|
+
*/
|
|
37
|
+
const TSCONFIG = `{
|
|
38
|
+
"compilerOptions": {
|
|
39
|
+
"target": "esnext",
|
|
40
|
+
"module": "esnext",
|
|
41
|
+
"moduleResolution": "bundler",
|
|
42
|
+
"strict": true,
|
|
43
|
+
"noEmit": true,
|
|
44
|
+
"skipLibCheck": true,
|
|
45
|
+
"allowImportingTsExtensions": true
|
|
46
|
+
},
|
|
47
|
+
"include": ["config.ts"]
|
|
48
|
+
}
|
|
20
49
|
`;
|
|
21
50
|
|
|
22
51
|
const SMALL_FILES = `---
|
|
@@ -24,16 +53,20 @@ description: A file should be small and focused, with one primary responsibility
|
|
|
24
53
|
threshold: 0.8
|
|
25
54
|
---
|
|
26
55
|
|
|
27
|
-
A guideline, so its description and its
|
|
56
|
+
A guideline, so its description and its headings say should and should not. A
|
|
28
57
|
rule that must hold says must and never instead, as name-domain-actions does.
|
|
29
58
|
Use this rule as a template for repository-specific conventions.
|
|
30
59
|
|
|
31
|
-
|
|
60
|
+
## Should
|
|
61
|
+
|
|
62
|
+
\`\`\`ts
|
|
32
63
|
export const parsePort = (value: string) =>
|
|
33
64
|
Port.make(Number.parseInt(value, 10));
|
|
34
65
|
\`\`\`
|
|
35
66
|
|
|
36
|
-
|
|
67
|
+
## Should not
|
|
68
|
+
|
|
69
|
+
\`\`\`ts
|
|
37
70
|
export const parsePort = (value: string) => Port.make(Number.parseInt(value, 10));
|
|
38
71
|
export const sendWelcomeEmail = (to: Email) => Mailer.send(to, welcomeTemplate);
|
|
39
72
|
export const renderInvoice = (invoice: Invoice) => Html.table(invoice.lines);
|
|
@@ -45,17 +78,21 @@ description: A function must be named after the domain action it performs, never
|
|
|
45
78
|
threshold: 0.8
|
|
46
79
|
---
|
|
47
80
|
|
|
48
|
-
|
|
81
|
+
## Must
|
|
82
|
+
|
|
83
|
+
\`\`\`ts
|
|
49
84
|
export const loadCustomerProfile = (customerId: CustomerId) =>
|
|
50
85
|
Effect.gen(function* () {
|
|
51
86
|
return yield* CustomerStore.find(customerId);
|
|
52
87
|
});
|
|
53
88
|
\`\`\`
|
|
54
89
|
|
|
55
|
-
|
|
56
|
-
a rule can have one without the block above.
|
|
90
|
+
## Never
|
|
57
91
|
|
|
58
|
-
|
|
92
|
+
The code under Never shows what a violation looks like. It is optional, and a
|
|
93
|
+
rule can have it without the code under Must.
|
|
94
|
+
|
|
95
|
+
\`\`\`ts
|
|
59
96
|
export const handle = (id: string) =>
|
|
60
97
|
Effect.gen(function* () {
|
|
61
98
|
return yield* CustomerStore.find(id);
|
|
@@ -65,8 +102,12 @@ export const handle = (id: string) =>
|
|
|
65
102
|
|
|
66
103
|
const CONFIG_PATH = CONFIG_FILES[0];
|
|
67
104
|
|
|
105
|
+
/** The config's own project, which only a config at `.adhere/config.ts` needs. */
|
|
106
|
+
const CONFIG_PROJECT = ".adhere/tsconfig.json";
|
|
107
|
+
|
|
68
108
|
const files = [
|
|
69
109
|
[CONFIG_PATH, CONFIG],
|
|
110
|
+
[CONFIG_PROJECT, TSCONFIG],
|
|
70
111
|
[".adhere/style/prefer-small-files.md", SMALL_FILES],
|
|
71
112
|
[".adhere/style/name-domain-actions.md", NAME_EFFECTS],
|
|
72
113
|
] as const;
|
|
@@ -102,6 +143,69 @@ const writeScaffoldFile = (root: string, path: string, contents: string, options
|
|
|
102
143
|
),
|
|
103
144
|
});
|
|
104
145
|
|
|
146
|
+
const PACKAGE = "@drkmttr/adhere";
|
|
147
|
+
|
|
148
|
+
type PackageManager = "bun" | "pnpm" | "yarn" | "npm";
|
|
149
|
+
|
|
150
|
+
/** The first lockfile at the root names the package manager; npm when there is none. */
|
|
151
|
+
const lockfiles: ReadonlyArray<readonly [string, PackageManager]> = [
|
|
152
|
+
["bun.lock", "bun"],
|
|
153
|
+
["bun.lockb", "bun"],
|
|
154
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
155
|
+
["yarn.lock", "yarn"],
|
|
156
|
+
["package-lock.json", "npm"],
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const packageManagerAt = async (root: string): Promise<PackageManager> => {
|
|
160
|
+
for (const [file, manager] of lockfiles) {
|
|
161
|
+
if (await exists(join(root, file))) return manager;
|
|
162
|
+
}
|
|
163
|
+
return "npm";
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* pnpm refuses to add to a workspace root without a flag. `-w` fails outside a
|
|
168
|
+
* workspace, so the first one, which works in both, stands in for it. pnpm 11
|
|
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.
|
|
172
|
+
*/
|
|
173
|
+
const addFlags: Record<PackageManager, ReadonlyArray<string>> = {
|
|
174
|
+
bun: [],
|
|
175
|
+
pnpm: ["--ignore-workspace-root-check", "--config.strict-dep-builds=false"],
|
|
176
|
+
yarn: [],
|
|
177
|
+
npm: [],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Adds adhere to devDependencies, so the config's `import type` resolves. */
|
|
181
|
+
const installAdhere = (root: string) =>
|
|
182
|
+
Effect.tryPromise({
|
|
183
|
+
try: async (): Promise<Install> => {
|
|
184
|
+
const manifest = await readFile(join(root, "package.json"), "utf8").catch(() => undefined);
|
|
185
|
+
if (manifest === undefined) return { status: "no-package-json" };
|
|
186
|
+
const { dependencies, devDependencies } = JSON.parse(manifest);
|
|
187
|
+
if (dependencies?.[PACKAGE] !== undefined || devDependencies?.[PACKAGE] !== undefined) {
|
|
188
|
+
return { status: "listed" };
|
|
189
|
+
}
|
|
190
|
+
const packageManager = await packageManagerAt(root);
|
|
191
|
+
await new Promise<void>((resolve, reject) => {
|
|
192
|
+
spawn(packageManager, ["add", "-D", PACKAGE, ...addFlags[packageManager]], {
|
|
193
|
+
cwd: root,
|
|
194
|
+
stdio: "inherit",
|
|
195
|
+
})
|
|
196
|
+
.on("error", reject)
|
|
197
|
+
.on("exit", (code) =>
|
|
198
|
+
code === 0 ? resolve() : reject(new Error(`${packageManager} exited with ${code}`)),
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
return { status: "installed", packageManager };
|
|
202
|
+
},
|
|
203
|
+
catch: (cause) =>
|
|
204
|
+
new Error(
|
|
205
|
+
`Could not add ${PACKAGE} to package.json: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
206
|
+
),
|
|
207
|
+
});
|
|
208
|
+
|
|
105
209
|
export const initProject = (
|
|
106
210
|
root: string,
|
|
107
211
|
options: InitOptions = {},
|
|
@@ -115,6 +219,8 @@ export const initProject = (
|
|
|
115
219
|
skipped.push(existing);
|
|
116
220
|
continue;
|
|
117
221
|
}
|
|
222
|
+
// A config at another path is in the project's own tsconfig, or not ours to place.
|
|
223
|
+
if (path === CONFIG_PROJECT && existing !== undefined) continue;
|
|
118
224
|
const status = yield* writeScaffoldFile(root, path, contents, options);
|
|
119
225
|
if (status === "created") {
|
|
120
226
|
created.push(relative(root, join(root, path)));
|
|
@@ -122,5 +228,6 @@ export const initProject = (
|
|
|
122
228
|
skipped.push(relative(root, join(root, path)));
|
|
123
229
|
}
|
|
124
230
|
}
|
|
125
|
-
|
|
231
|
+
const install = yield* installAdhere(root);
|
|
232
|
+
return { created, skipped, install };
|
|
126
233
|
});
|
package/src/main.ts
CHANGED
|
@@ -49,7 +49,6 @@ const report = (cause: Cause.Cause<unknown>): Effect.Effect<void> => {
|
|
|
49
49
|
|
|
50
50
|
Command.run(cli, { version }).pipe(
|
|
51
51
|
Effect.tapCause(report),
|
|
52
|
-
// oxlint-disable-next-line effecttsgo/strict-effect-provide -- this executable entrypoint supplies Bun's process services and the logger.
|
|
53
52
|
Effect.provide(Layer.merge(BunServices.layer, logger)),
|
|
54
53
|
(program) => BunRuntime.runMain(program, { disableErrorReporting: true }),
|
|
55
54
|
);
|
package/src/markdown.ts
CHANGED
|
@@ -10,27 +10,40 @@ import { Effect, Schema } from "effect";
|
|
|
10
10
|
* ---
|
|
11
11
|
* Any prose, rendered on GitHub and ignored by adhere.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* ## Must
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
14
16
|
* const written = "code that must be written this way"
|
|
15
17
|
* ```
|
|
16
18
|
*
|
|
17
|
-
*
|
|
19
|
+
* ## Never
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
18
22
|
* const avoided = "code that must never be written this way"
|
|
19
23
|
* ```
|
|
20
24
|
*
|
|
21
|
-
* A
|
|
22
|
-
* `never` for a requirement, `should` and `should not` for a guideline.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
25
|
+
* A heading names the code in its section with RFC 2119's words: `must` and
|
|
26
|
+
* `never` for a requirement, `should` and `should not` for a guideline. So
|
|
27
|
+
* does a fence's info string, as in `ts never`, which GitHub does not show; a
|
|
28
|
+
* fence's own word wins over its heading's. The first fence under each word is
|
|
29
|
+
* that example, and a rule has either or both. An untagged fence outside those
|
|
30
|
+
* sections is the code to write, and without a fence the whole body is.
|
|
31
|
+
* `avoid`, the word before 0.7, means `never`. `threshold` is optional, and
|
|
32
|
+
* so is `tests`: `only` for a rule about test files, which rules otherwise
|
|
33
|
+
* skip, and `include` for a rule that judges them too. So is `level`:
|
|
34
|
+
* `warning` reports the rule's findings as warnings, which do not fail a run.
|
|
26
35
|
*/
|
|
27
36
|
const FrontMatter = Schema.Struct({
|
|
28
37
|
description: Schema.String,
|
|
29
38
|
threshold: Schema.optionalKey(Schema.FiniteFromString),
|
|
39
|
+
tests: Schema.optionalKey(Schema.Literals(["only", "include"])),
|
|
40
|
+
level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
|
|
30
41
|
});
|
|
31
42
|
|
|
32
43
|
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
33
44
|
const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm;
|
|
45
|
+
/** An ATX heading: its level as `#`s, then its text, without a closing run of `#`s. */
|
|
46
|
+
const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/gm;
|
|
34
47
|
|
|
35
48
|
const unquote = (value: string): string => {
|
|
36
49
|
const trimmed = value.trim();
|
|
@@ -54,11 +67,17 @@ const parseFrontMatter = (block: string): Record<string, string> => {
|
|
|
54
67
|
type Word = Example["word"];
|
|
55
68
|
|
|
56
69
|
interface Fence {
|
|
57
|
-
/** The word
|
|
70
|
+
/** The word its info string, or else its heading, names the code with; none for untagged code. */
|
|
58
71
|
readonly word: Word | undefined;
|
|
59
72
|
readonly code: string;
|
|
60
73
|
}
|
|
61
74
|
|
|
75
|
+
interface Heading {
|
|
76
|
+
readonly level: number;
|
|
77
|
+
/** The word the heading names its section's code with; none for any other heading. */
|
|
78
|
+
readonly word: Word | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
/** `must not`, like `never` and the older `avoid`, names code never to write. */
|
|
63
82
|
const wordOf = (info: string): Word | undefined => {
|
|
64
83
|
const words = info.trim().toLowerCase().split(/\s+/);
|
|
@@ -69,11 +88,55 @@ const wordOf = (info: string): Word | undefined => {
|
|
|
69
88
|
return undefined;
|
|
70
89
|
};
|
|
71
90
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
const HEADING_WORDS: ReadonlySet<string> = new Set([
|
|
92
|
+
"must",
|
|
93
|
+
"must not",
|
|
94
|
+
"never",
|
|
95
|
+
"avoid",
|
|
96
|
+
"should",
|
|
97
|
+
"should not",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A heading names a word only when it is the word alone, in any case and with
|
|
102
|
+
* or without a colon: `## Never` does, `## Why never` does not.
|
|
103
|
+
*/
|
|
104
|
+
const headingWordOf = (text: string): Word | undefined => {
|
|
105
|
+
const phrase = text.trim().replace(/:$/, "").trim().toLowerCase().split(/\s+/).join(" ");
|
|
106
|
+
return HEADING_WORDS.has(phrase) ? wordOf(phrase) : undefined;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The headings open after `text`, outermost first, given those open before
|
|
111
|
+
* it. A heading closes the sections at its level and deeper, so a deeper
|
|
112
|
+
* heading stays inside the section above it.
|
|
113
|
+
*/
|
|
114
|
+
const openAfter = (open: ReadonlyArray<Heading>, text: string): ReadonlyArray<Heading> =>
|
|
115
|
+
Array.from(text.matchAll(HEADING)).reduce<ReadonlyArray<Heading>>((headings, match) => {
|
|
116
|
+
const level = (match[1] ?? "#").length;
|
|
117
|
+
return [
|
|
118
|
+
...headings.filter((heading) => heading.level < level),
|
|
119
|
+
{ level, word: headingWordOf(match[2] ?? "") },
|
|
120
|
+
];
|
|
121
|
+
}, open);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Each fence, under the word its info string names or, without one, the word
|
|
125
|
+
* of the innermost heading over it that names one.
|
|
126
|
+
*/
|
|
127
|
+
const fencesOf = (body: string): ReadonlyArray<Fence> => {
|
|
128
|
+
const fences: Array<Fence> = [];
|
|
129
|
+
let open: ReadonlyArray<Heading> = [];
|
|
130
|
+
let read = 0;
|
|
131
|
+
for (const match of body.matchAll(FENCE)) {
|
|
132
|
+
// Only headings between fences count: a `#` line inside one is code.
|
|
133
|
+
open = openAfter(open, body.slice(read, match.index));
|
|
134
|
+
read = match.index + match[0].length;
|
|
135
|
+
const section = open.findLast((heading) => heading.word !== undefined)?.word;
|
|
136
|
+
fences.push({ word: wordOf(match[2] ?? "") ?? section, code: (match[3] ?? "").trim() });
|
|
137
|
+
}
|
|
138
|
+
return fences;
|
|
139
|
+
};
|
|
77
140
|
|
|
78
141
|
/** An empty block counts as no block. */
|
|
79
142
|
const nonEmpty = (code: string | undefined): string | undefined =>
|
|
@@ -134,7 +197,7 @@ export const parseRuleMarkdown = (
|
|
|
134
197
|
}
|
|
135
198
|
if (!requirement && !guideline) {
|
|
136
199
|
return yield* ConfigUnavailable.make({
|
|
137
|
-
message: `${file}: the body needs code, in a fence
|
|
200
|
+
message: `${file}: the body needs code, in a fence under a Must, Never, Should, or Should not heading, or untagged`,
|
|
138
201
|
});
|
|
139
202
|
}
|
|
140
203
|
return { ...front, ...code };
|
package/src/models/Audit.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Schema } from "effect";
|
|
|
3
3
|
export interface ScannedFile {
|
|
4
4
|
readonly path: string;
|
|
5
5
|
readonly lines: ReadonlyArray<string>;
|
|
6
|
+
/** A test file, which only the rules that say `tests` judge. */
|
|
7
|
+
readonly test?: boolean;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
/** A source walker's refusal: the tree it was asked to read is not readable. */
|
package/src/presets.ts
CHANGED
|
@@ -4,10 +4,13 @@ import type { Preset } from "#config.ts";
|
|
|
4
4
|
* `presets/` sits next to `src/` in a checkout. In a compiled executable
|
|
5
5
|
* (`bun build --compile --asset ./presets`) Bun embeds it under the bundle's
|
|
6
6
|
* own directory instead, hence the fork on `Bun.isStandaloneExecutable`, which
|
|
7
|
-
* is the check Bun's docs give for this. Measured on Bun 1.4.
|
|
8
|
-
*
|
|
7
|
+
* is the check Bun's docs give for this. Measured on Bun 1.4. It is read off
|
|
8
|
+
* `globalThis` for Vitest, which imports this module on Node, and for a
|
|
9
|
+
* project that type-checks this package's source without Bun's types.
|
|
9
10
|
*/
|
|
10
|
-
const isStandaloneExecutable =
|
|
11
|
+
const isStandaloneExecutable =
|
|
12
|
+
(globalThis as { readonly Bun?: { readonly isStandaloneExecutable?: boolean } }).Bun
|
|
13
|
+
?.isStandaloneExecutable === true;
|
|
11
14
|
|
|
12
15
|
const presetsRoot = isStandaloneExecutable
|
|
13
16
|
? new URL("./presets/", import.meta.url)
|
package/src/rules.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ConfigUnavailable,
|
|
3
|
+
type Override,
|
|
3
4
|
type Rule,
|
|
4
5
|
type RuleId,
|
|
5
6
|
type RuleSource,
|
|
@@ -30,8 +31,10 @@ export interface RuleEntry {
|
|
|
30
31
|
* mixes presets with the project's own rules says where each came from. The
|
|
31
32
|
* project's own rules show their `.adhere/` ids as they are.
|
|
32
33
|
*/
|
|
33
|
-
export const shownId = (entry: {
|
|
34
|
-
|
|
34
|
+
export const shownId = (entry: {
|
|
35
|
+
readonly id: RuleId;
|
|
36
|
+
readonly preset?: string | undefined;
|
|
37
|
+
}): string => (entry.preset === undefined ? entry.id : `${entry.preset}/${entry.id}`);
|
|
35
38
|
|
|
36
39
|
export type RuleSet = ReadonlyArray<RuleEntry>;
|
|
37
40
|
|
|
@@ -170,6 +173,36 @@ export const materializeRules = Effect.fn("materializeRules")(function* (
|
|
|
170
173
|
export const globalRuleSet = (rules: Readonly<Record<RuleId, Rule>>, root: string) =>
|
|
171
174
|
Object.entries(rules).map(([id, rule]): RuleEntry => ({ id, rule, scope: root }));
|
|
172
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The rules as a config's overrides leave them, each found by the id a report
|
|
178
|
+
* names it with: a rule set `off` gone, and any other with the level and
|
|
179
|
+
* threshold its override gives, over its own.
|
|
180
|
+
*/
|
|
181
|
+
export const withOverrides = (
|
|
182
|
+
entries: RuleSet,
|
|
183
|
+
overrides: Readonly<Record<string, Override>>,
|
|
184
|
+
): RuleSet =>
|
|
185
|
+
entries.flatMap((entry) => {
|
|
186
|
+
const override = overrides[shownId(entry)];
|
|
187
|
+
if (override === undefined) return [entry];
|
|
188
|
+
const { level, threshold }: Exclude<Override, string> =
|
|
189
|
+
typeof override === "string" ? { level: override } : override;
|
|
190
|
+
if (level === "off") return [];
|
|
191
|
+
const rule: Rule = {
|
|
192
|
+
...entry.rule,
|
|
193
|
+
...(level === undefined ? {} : { level }),
|
|
194
|
+
...(threshold === undefined ? {} : { threshold }),
|
|
195
|
+
};
|
|
196
|
+
return [{ ...entry, rule }];
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether a rule judges a file: a test file only when the rule says `tests`,
|
|
201
|
+
* and any other file unless it says `tests: only`.
|
|
202
|
+
*/
|
|
203
|
+
export const judgesFile = (rule: Rule, test: boolean): boolean =>
|
|
204
|
+
test ? rule.tests !== undefined : rule.tests !== "only";
|
|
205
|
+
|
|
173
206
|
/**
|
|
174
207
|
* The flat rule map for one source file. Root rules apply everywhere. When a
|
|
175
208
|
* nested `.adhere/` defines the same id, the deepest matching scope wins.
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ADHERE_DIRECTORY,
|
|
3
|
-
|
|
3
|
+
AdhereConfig as AdhereConfigSchema,
|
|
4
4
|
CONFIG_FILES,
|
|
5
5
|
ConfigUnavailable,
|
|
6
6
|
decodeConfig,
|
|
7
7
|
type Loaded,
|
|
8
|
-
type
|
|
8
|
+
type Flags,
|
|
9
9
|
type Preset,
|
|
10
10
|
presetsOf,
|
|
11
11
|
type ResolvedConfig,
|
|
12
12
|
resolveConfig,
|
|
13
13
|
} from "#config.ts";
|
|
14
|
-
import { type PresetName, presetOf, wholePresetOf } from "#presets.ts";
|
|
14
|
+
import { type PresetName, presetOf, presets, wholePresetOf } from "#presets.ts";
|
|
15
15
|
import {
|
|
16
16
|
applicableRules,
|
|
17
17
|
globalRuleSet,
|
|
18
18
|
loadAdhereRuleSet,
|
|
19
19
|
materializeRules,
|
|
20
20
|
type RuleSet,
|
|
21
|
+
shownId,
|
|
22
|
+
withOverrides,
|
|
21
23
|
} from "#rules.ts";
|
|
22
24
|
import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
|
|
23
25
|
|
|
@@ -50,13 +52,23 @@ const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetN
|
|
|
50
52
|
return [name, { ...preset, rules } satisfies Loaded<Preset>] as const;
|
|
51
53
|
});
|
|
52
54
|
|
|
55
|
+
/** Every rule of every built-in preset, by the id a report names it with. */
|
|
56
|
+
const everyPresetRule = Effect.fn("AdhereConfig.everyPresetRule")(function* (base: string) {
|
|
57
|
+
const loaded = yield* Effect.forEach(Object.entries(presets), ([name, preset]) =>
|
|
58
|
+
Effect.map(materializeRules(preset.rules, base), (rules) =>
|
|
59
|
+
Object.keys(rules).map((id) => `${name}/${id}`),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
return new Set(loaded.flat());
|
|
63
|
+
});
|
|
64
|
+
|
|
53
65
|
/**
|
|
54
66
|
* The config file in the working directory (one of `CONFIG_FILES`) with the
|
|
55
|
-
* command-line
|
|
67
|
+
* command-line flags applied. The file is optional when `.adhere/` holds
|
|
56
68
|
* rules or a preset is named. Rule directories resolve against the working
|
|
57
69
|
* directory.
|
|
58
70
|
*/
|
|
59
|
-
export const AdhereConfigLive = (
|
|
71
|
+
export const AdhereConfigLive = (flags: Flags) =>
|
|
60
72
|
Layer.effect(AdhereConfig)(
|
|
61
73
|
Effect.gen(function* () {
|
|
62
74
|
const path = yield* Path.Path;
|
|
@@ -73,8 +85,8 @@ export const AdhereConfigLive = (overrides: Overrides) =>
|
|
|
73
85
|
});
|
|
74
86
|
}
|
|
75
87
|
const configFile = found[0];
|
|
76
|
-
const hasPreset =
|
|
77
|
-
const config:
|
|
88
|
+
const hasPreset = flags.presets !== undefined && flags.presets.length > 0;
|
|
89
|
+
const config: typeof AdhereConfigSchema.Type =
|
|
78
90
|
configFile === undefined
|
|
79
91
|
? yield* decodeConfig({})
|
|
80
92
|
: yield* loadConfigFile(path.join(cwd, configFile));
|
|
@@ -90,10 +102,10 @@ export const AdhereConfigLive = (overrides: Overrides) =>
|
|
|
90
102
|
? discoveredEntries
|
|
91
103
|
: globalRuleSet(yield* materializeRules(config.rules ?? {}, cwd), cwd);
|
|
92
104
|
const rules = applicableRules(cwd, projectEntries);
|
|
93
|
-
const registry = yield* Effect.forEach(presetsOf(config,
|
|
105
|
+
const registry = yield* Effect.forEach(presetsOf(config, flags), (name) =>
|
|
94
106
|
loadPreset(name, cwd),
|
|
95
107
|
).pipe(Effect.map(Record.fromEntries));
|
|
96
|
-
const resolved = resolveConfig({ ...config, rules },
|
|
108
|
+
const resolved = resolveConfig({ ...config, rules }, flags, registry);
|
|
97
109
|
const presetEntries = Object.entries(registry).flatMap(([name, preset]) =>
|
|
98
110
|
globalRuleSet(preset.rules, cwd).map((entry) => ({
|
|
99
111
|
...entry,
|
|
@@ -103,9 +115,20 @@ export const AdhereConfigLive = (overrides: Overrides) =>
|
|
|
103
115
|
yield* Effect.logDebug(
|
|
104
116
|
`config: ${configFile ?? "no config file"}; presets ${Object.keys(registry).join(", ") || "none"}; ${presetEntries.length} preset and ${projectEntries.length} project rules; model ${resolved.model}, threshold ${resolved.threshold}${resolved.rpm === undefined ? "" : `, at most ${resolved.rpm} requests a minute`}`,
|
|
105
117
|
);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
const entries = [...presetEntries, ...projectEntries];
|
|
119
|
+
const overrides = config.overrides ?? {};
|
|
120
|
+
const loaded = new Set(entries.map(shownId));
|
|
121
|
+
const elsewhere = Object.keys(overrides).filter((id) => !loaded.has(id));
|
|
122
|
+
if (elsewhere.length > 0) {
|
|
123
|
+
// A rule of a preset this run leaves out may be overridden; one no rule has is a typo.
|
|
124
|
+
const every = yield* everyPresetRule(cwd);
|
|
125
|
+
const unknown = elsewhere.filter((id) => !every.has(id));
|
|
126
|
+
if (unknown.length > 0) {
|
|
127
|
+
return yield* ConfigUnavailable.make({
|
|
128
|
+
message: `overrides names ${unknown.join(", ")}, which is no preset or project rule. A preset's rule is named with its preset first, as in alchemy/providers/idempotent-delete.`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return AdhereConfig.of({ ...resolved, scopedRules: withOverrides(entries, overrides) });
|
|
110
133
|
}),
|
|
111
134
|
);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { matchesGlob } from "node:path";
|
|
2
2
|
import { Context, Effect, FileSystem, Layer, Path } from "effect";
|
|
3
|
-
import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
|
|
3
|
+
import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES, TEST_DIRECTORIES } from "#config.ts";
|
|
4
4
|
import type { ScannedFile } from "#models/Audit.ts";
|
|
5
5
|
import { WalkUnavailable } from "#models/Audit.ts";
|
|
6
6
|
import { walkFiles } from "#walk.ts";
|
|
7
|
+
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
7
8
|
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
8
9
|
|
|
9
10
|
/** Walks the repository's TypeScript source, skipping what is not ours to read. */
|
|
@@ -11,9 +12,9 @@ export class SourceWalker extends Context.Service<
|
|
|
11
12
|
SourceWalker,
|
|
12
13
|
{
|
|
13
14
|
/**
|
|
14
|
-
* Every TypeScript file under the audit's roots, in directory order
|
|
15
|
-
* file that cannot be read refuses the walk: a silent gap
|
|
16
|
-
* a clean audit that was never complete.
|
|
15
|
+
* Every TypeScript file under the audit's roots, in directory order, test
|
|
16
|
+
* files marked. A file that cannot be read refuses the walk: a silent gap
|
|
17
|
+
* would report a clean audit that was never complete.
|
|
17
18
|
*/
|
|
18
19
|
readonly files: Effect.Effect<ReadonlyArray<ScannedFile>, WalkUnavailable>;
|
|
19
20
|
}
|
|
@@ -50,13 +51,26 @@ export const passesFilter = (relative: string, patterns: ReadonlyArray<string>):
|
|
|
50
51
|
return (include.length === 0 || include.some(matches)) && !exclude.some(matches);
|
|
51
52
|
};
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Whether a path, relative to the working directory, is a test: a `.test.ts`
|
|
56
|
+
* or `.spec.ts` file, or any file under a test directory such as `test/`.
|
|
57
|
+
*/
|
|
58
|
+
export const isTestFile = (relative: string): boolean => {
|
|
59
|
+
const segments = relative.split(/[\\/]/);
|
|
60
|
+
const name = segments.at(-1) ?? "";
|
|
61
|
+
return (
|
|
62
|
+
name.endsWith(".test.ts") ||
|
|
63
|
+
name.endsWith(".spec.ts") ||
|
|
64
|
+
segments.slice(0, -1).some((segment) => TEST_DIRECTORIES.has(segment))
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
53
68
|
const isInside = (file: string, root: string): boolean =>
|
|
54
69
|
file === root || file.startsWith(`${root}/`);
|
|
55
70
|
|
|
56
71
|
const isScannable = (file: string, self: string): boolean =>
|
|
57
72
|
file.endsWith(".ts") &&
|
|
58
73
|
!file.endsWith(".d.ts") &&
|
|
59
|
-
!file.endsWith(".test.ts") &&
|
|
60
74
|
!file.endsWith("/adhere.config.ts") &&
|
|
61
75
|
!file.endsWith("/.adhere.config.ts") &&
|
|
62
76
|
// The audit's own detector patterns are data, not violations of themselves.
|
|
@@ -169,8 +183,10 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
|
|
|
169
183
|
|
|
170
184
|
/**
|
|
171
185
|
* The real walker: the roots walked a directory at a time, skipped trees
|
|
172
|
-
* left unread, each file read and split into lines.
|
|
173
|
-
*
|
|
186
|
+
* left unread, each file read and split into lines. The run's filter and the
|
|
187
|
+
* config's `exclude` leave out what they match. Test files are read, and
|
|
188
|
+
* marked, even when no rule judges them, so a prune keeps what other runs
|
|
189
|
+
* know of them. Tests substitute their own tree.
|
|
174
190
|
* The scan root is the current working directory, so the audit reads the
|
|
175
191
|
* repository it is run in.
|
|
176
192
|
*/
|
|
@@ -179,9 +195,11 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
|
|
|
179
195
|
Effect.gen(function* () {
|
|
180
196
|
const fs = yield* FileSystem.FileSystem;
|
|
181
197
|
const path = yield* Path.Path;
|
|
198
|
+
const config = yield* AdhereConfig;
|
|
182
199
|
// No segments: Path resolves against the working directory.
|
|
183
200
|
const root = path.resolve();
|
|
184
201
|
const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
|
|
202
|
+
const patterns = [...filter, ...(config.exclude ?? []).map((glob) => `!${glob}`)];
|
|
185
203
|
const walk = Effect.fn("SourceWalker.files")(function* () {
|
|
186
204
|
const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
|
|
187
205
|
const trees = yield* clearingStatus(
|
|
@@ -193,12 +211,16 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
|
|
|
193
211
|
dir === "." ? "the working directory" : `${dir}/`,
|
|
194
212
|
self,
|
|
195
213
|
(relative) =>
|
|
196
|
-
//
|
|
197
|
-
passesFilter(dir === "." ? relative : `${dir}/${relative}`,
|
|
214
|
+
// Patterns are relative to the working directory, not the tree read.
|
|
215
|
+
passesFilter(dir === "." ? relative : `${dir}/${relative}`, patterns),
|
|
198
216
|
).pipe(Effect.mapError(refused)),
|
|
199
217
|
),
|
|
200
218
|
);
|
|
201
|
-
return trees
|
|
219
|
+
return trees
|
|
220
|
+
.flat()
|
|
221
|
+
.map((file) =>
|
|
222
|
+
isTestFile(path.relative(root, file.path)) ? { ...file, test: true } : file,
|
|
223
|
+
);
|
|
202
224
|
});
|
|
203
225
|
return SourceWalker.of({ files: walk() });
|
|
204
226
|
}),
|