@drkmttr/adhere 0.9.4 → 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 +257 -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 +13 -3
- 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/mechanical.ts +2 -2
- package/src/models/Audit.ts +2 -0
- package/src/presets.ts +9 -3
- package/src/pricing.ts +14 -0
- package/src/rules.ts +43 -1
- package/src/services/AdhereConfig.ts +40 -14
- package/src/services/Jev.ts +17 -0
- package/src/services/SourceWalker.ts +32 -10
- package/src/suppress.ts +113 -0
- package/src/wording.ts +2 -2
- package/src/workflows/audit.ts +137 -27
- package/src/workflows/format.ts +62 -15
package/src/contradictions.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Rule } from "#config.ts";
|
|
2
|
+
import { judgesFile, type RuleEntry, shownId } from "#rules.ts";
|
|
2
3
|
import { Jev, type Pair } from "#services/Jev.ts";
|
|
3
4
|
import { Effect } from "effect";
|
|
4
5
|
|
|
@@ -17,6 +18,10 @@ const scopesOverlap = (a: string, b: string): boolean => {
|
|
|
17
18
|
return first === second || first.startsWith(`${second}/`) || second.startsWith(`${first}/`);
|
|
18
19
|
};
|
|
19
20
|
|
|
21
|
+
/** Whether two rules judge any of the same kind of file: a rule on tests only shares none with one that skips them. */
|
|
22
|
+
const judgeSameKind = (a: Rule, b: Rule): boolean =>
|
|
23
|
+
(judgesFile(a, true) && judgesFile(b, true)) || (judgesFile(a, false) && judgesFile(b, false));
|
|
24
|
+
|
|
20
25
|
/**
|
|
21
26
|
* Per rule, the rules that apply to some of the same files. A rule with the
|
|
22
27
|
* same id is left out: a nested rule that shares an id shadows the other on
|
|
@@ -25,7 +30,10 @@ const scopesOverlap = (a: string, b: string): boolean => {
|
|
|
25
30
|
const partnersOf = (entries: ReadonlyArray<RuleEntry>): ReadonlyArray<ReadonlyArray<number>> =>
|
|
26
31
|
entries.map((first, index) =>
|
|
27
32
|
entries.flatMap((second, other) =>
|
|
28
|
-
other !== index &&
|
|
33
|
+
other !== index &&
|
|
34
|
+
first.id !== second.id &&
|
|
35
|
+
scopesOverlap(first.scope, second.scope) &&
|
|
36
|
+
judgeSameKind(first.rule, second.rule)
|
|
29
37
|
? [other]
|
|
30
38
|
: [],
|
|
31
39
|
),
|
|
@@ -69,7 +77,9 @@ export const findContradictions = Effect.fn("findContradictions")(function* (
|
|
|
69
77
|
});
|
|
70
78
|
});
|
|
71
79
|
|
|
72
|
-
|
|
80
|
+
/** Where a rule lives: a preset's names the preset in its id; the project's own rule names its file. */
|
|
81
|
+
const locationOf = (entry: RuleEntry): string =>
|
|
82
|
+
entry.preset === undefined ? `${entry.id} (${entry.file ?? entry.scope})` : shownId(entry);
|
|
73
83
|
|
|
74
84
|
export const formatContradictions = (contradictions: ReadonlyArray<Contradiction>): string => {
|
|
75
85
|
if (contradictions.length === 0) {
|
package/src/excerpt.ts
CHANGED
|
@@ -16,7 +16,7 @@ const AROUND = 3;
|
|
|
16
16
|
const WHOLE = 30;
|
|
17
17
|
|
|
18
18
|
/** Lines of the code, first to last, counting from 1. */
|
|
19
|
-
interface Range {
|
|
19
|
+
export interface Range {
|
|
20
20
|
readonly first: number;
|
|
21
21
|
readonly last: number;
|
|
22
22
|
}
|
|
@@ -209,6 +209,16 @@ const statementsAround = (
|
|
|
209
209
|
];
|
|
210
210
|
};
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The statement that starts on `line`, such as a property, a function, or a
|
|
214
|
+
* call that opens there: the outermost statement around `line` whose first
|
|
215
|
+
* line it is. None when no statement starts on `line`.
|
|
216
|
+
*/
|
|
217
|
+
export const statementFrom = (lines: ReadonlyArray<string>, line: number): Range | undefined => {
|
|
218
|
+
const { pieces } = piecesOf(lines.join("\n"));
|
|
219
|
+
return statementsAround(pieces, closesOf(pieces), line).find((range) => range.first === line);
|
|
220
|
+
};
|
|
221
|
+
|
|
212
222
|
/**
|
|
213
223
|
* The excerpt for a finding on `line`: the outermost statement around it
|
|
214
224
|
* with at most `WHOLE` lines, such as the function it is in, and up to
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ export {
|
|
|
10
10
|
type RuleSource,
|
|
11
11
|
} from "#config.ts";
|
|
12
12
|
export { findContradictions, formatContradictions, type Contradiction } from "#contradictions.ts";
|
|
13
|
-
export { initProject, type InitOptions, type InitResult } from "#init.ts";
|
|
13
|
+
export { initProject, type InitOptions, type InitResult, type Install } from "#init.ts";
|
|
14
14
|
export { parseRuleMarkdown } from "#markdown.ts";
|
|
15
15
|
export { type PresetName, presetNames } from "#presets.ts";
|
|
16
16
|
export {
|
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/mechanical.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Rule } from "#config.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { type RuleEntry, shownId } from "#rules.ts";
|
|
3
3
|
import { AuditCache, sha256, type Tally } from "#services/AuditCache.ts";
|
|
4
4
|
import { linterQuestion } from "#services/Jev.ts";
|
|
5
5
|
import { Effect } from "effect";
|
|
@@ -76,7 +76,7 @@ export const formatLinterCheck = (
|
|
|
76
76
|
: [
|
|
77
77
|
`${flagged.length} ${flagged.length === 1 ? "rule" : "rules"} flagged by the linter check:`,
|
|
78
78
|
...flagged.flatMap((one) => [
|
|
79
|
-
` ${one.entry
|
|
79
|
+
` ${shownId(one.entry)}${one.entry.file === undefined ? "" : ` (${displayPath(one.entry.file, root)})`}`,
|
|
80
80
|
` ${adviceOf(one)}`,
|
|
81
81
|
]),
|
|
82
82
|
]),
|
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)
|
|
@@ -57,4 +60,7 @@ export const presets: Readonly<Record<WholePreset, BuiltInPreset>> = {
|
|
|
57
60
|
};
|
|
58
61
|
|
|
59
62
|
/** The directory each whole preset reads, for checking `topics` against it. */
|
|
63
|
+
/** The whole preset a name belongs to: effect, for effect and for effect/basics alike. */
|
|
64
|
+
export const wholePresetOf = (name: PresetName): string => name.split("/")[0] ?? name;
|
|
65
|
+
|
|
60
66
|
export const topicsOf = (preset: WholePreset): ReadonlyArray<string> => topics[preset];
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev's price for input tokens, in dollars per million, by the name a config
|
|
3
|
+
* sends as its model. Output tokens are free. From TypeSafe AI's models page,
|
|
4
|
+
* https://docs.typesafe.ai/models, on 2026-09-25, when jev-latest and
|
|
5
|
+
* jev-preview both pointed to jev-1.13.0.
|
|
6
|
+
*/
|
|
7
|
+
const DOLLARS_PER_MILLION: Readonly<Record<string, number>> = {
|
|
8
|
+
"jev-1.13.0": 0.042,
|
|
9
|
+
"jev-latest": 0.042,
|
|
10
|
+
"jev-preview": 0.042,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** The model's price per million input tokens, when adhere knows it. */
|
|
14
|
+
export const priceOf = (model: string): number | undefined => DOLLARS_PER_MILLION[model];
|
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,
|
|
@@ -20,10 +21,21 @@ export interface RuleEntry {
|
|
|
20
21
|
readonly rule: Rule;
|
|
21
22
|
readonly scope: string;
|
|
22
23
|
readonly file?: string;
|
|
23
|
-
/** The built-in
|
|
24
|
+
/** The built-in preset the rule came from, by its whole name (effect for effect/basics), when it is not the project's own. */
|
|
24
25
|
readonly preset?: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A rule's id as output shows it: a preset's rule with the preset's name
|
|
30
|
+
* first, as in effect/basics/external-calls-are-resilient, so a report that
|
|
31
|
+
* mixes presets with the project's own rules says where each came from. The
|
|
32
|
+
* project's own rules show their `.adhere/` ids as they are.
|
|
33
|
+
*/
|
|
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}`);
|
|
38
|
+
|
|
27
39
|
export type RuleSet = ReadonlyArray<RuleEntry>;
|
|
28
40
|
|
|
29
41
|
const ADHERE_SEGMENT = ".adhere";
|
|
@@ -161,6 +173,36 @@ export const materializeRules = Effect.fn("materializeRules")(function* (
|
|
|
161
173
|
export const globalRuleSet = (rules: Readonly<Record<RuleId, Rule>>, root: string) =>
|
|
162
174
|
Object.entries(rules).map(([id, rule]): RuleEntry => ({ id, rule, scope: root }));
|
|
163
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
|
+
|
|
164
206
|
/**
|
|
165
207
|
* The flat rule map for one source file. Root rules apply everywhere. When a
|
|
166
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 } 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,19 +102,33 @@ 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
|
-
globalRuleSet(preset.rules, cwd).map((entry) => ({
|
|
110
|
+
globalRuleSet(preset.rules, cwd).map((entry) => ({
|
|
111
|
+
...entry,
|
|
112
|
+
preset: wholePresetOf(name as PresetName),
|
|
113
|
+
})),
|
|
99
114
|
);
|
|
100
115
|
yield* Effect.logDebug(
|
|
101
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`}`,
|
|
102
117
|
);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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) });
|
|
107
133
|
}),
|
|
108
134
|
);
|