@drkmttr/adhere 0.9.5 → 0.11.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 +406 -90
- package/package.json +7 -7
- 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/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/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/gen-for-sequencing.md +7 -3
- 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 +7 -3
- package/presets/effect/data/variants-are-tagged-unions.md +7 -3
- 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 +7 -3
- 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/presets/react/effects/clean-up-what-they-start.md +23 -0
- package/presets/react/effects/event-logic-in-handlers.md +29 -0
- package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
- package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
- package/presets/react/effects/notify-parents-in-handlers.md +29 -0
- package/presets/react/server/no-private-data-to-client.md +23 -0
- package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
- package/presets/react/state/no-contradictory-state.md +17 -0
- package/presets/react/state/no-props-copied-into-state.md +23 -0
- package/presets/react/state/store-ids-not-copies.md +20 -0
- package/presets/security/constant-time-secret-compare.md +22 -0
- package/presets/security/no-secrets-in-output.md +19 -0
- package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
- package/presets/security/parameterized-queries.md +16 -0
- package/presets/typescript/async/concurrent-independent-work.md +23 -0
- package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
- package/presets/typescript/design/no-argument-mutation.md +20 -0
- package/presets/typescript/errors/no-swallowed-errors.md +29 -0
- package/presets/typescript/errors/release-on-every-path.md +29 -0
- package/presets/typescript/testing/assert-outcomes.md +22 -0
- package/presets/typescript/testing/independent-tests.md +41 -0
- package/presets/typescript/types/assertions-only-when-proven.md +20 -0
- package/presets/typescript/types/derive-dont-restate.md +24 -0
- package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
- package/presets/typescript/types/validate-external-data.md +21 -0
- package/skills/adhere/SKILL.md +41 -12
- package/skills/adhere-fix/SKILL.md +180 -0
- package/src/cli.ts +59 -14
- package/src/comments.ts +36 -0
- package/src/config.ts +126 -14
- package/src/contradictions.ts +10 -2
- package/src/excerpt.ts +78 -58
- package/src/highlight.ts +2 -0
- package/src/index.ts +1 -1
- package/src/init.ts +119 -12
- package/src/main.ts +0 -1
- package/src/markdown.ts +83 -14
- package/src/models/Audit.ts +2 -0
- package/src/presets.ts +12 -3
- package/src/rules.ts +36 -3
- package/src/services/AdhereConfig.ts +36 -13
- package/src/services/AuditCache.ts +31 -5
- package/src/services/Jev.http.ts +76 -11
- package/src/services/Jev.ts +191 -73
- package/src/services/SourceWalker.ts +51 -13
- package/src/suppress.ts +119 -0
- package/src/version.ts +25 -1
- package/src/workflows/audit.ts +192 -40
- package/src/workflows/format.ts +71 -24
- package/presets/alchemy/data/parameterized-sql.md +0 -13
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -15
- package/presets/effect/basics/external-calls-are-resilient.md +0 -17
package/src/config.ts
CHANGED
|
@@ -17,6 +17,28 @@ const RuleFields = Schema.Struct({
|
|
|
17
17
|
should: Schema.optionalKey(Schema.String),
|
|
18
18
|
shouldNot: Schema.optionalKey(Schema.String),
|
|
19
19
|
threshold: Schema.optionalKey(Schema.Finite),
|
|
20
|
+
/**
|
|
21
|
+
* Test files, which a rule skips by default: `only` for a rule about tests,
|
|
22
|
+
* which judges nothing else, and `include` for one that holds in tests too.
|
|
23
|
+
*/
|
|
24
|
+
tests: Schema.optionalKey(Schema.Literals(["only", "include"])),
|
|
25
|
+
/**
|
|
26
|
+
* How a finding under the rule is reported: an `error`, the default, which
|
|
27
|
+
* fails the run, or a `warning`, for a nit or a rule that errs toward false
|
|
28
|
+
* findings, which does not.
|
|
29
|
+
*/
|
|
30
|
+
level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
|
|
31
|
+
/**
|
|
32
|
+
* Where the rule applies, each a description of code that breaks it: Jev is
|
|
33
|
+
* asked of each whether any code that breaks the rule is as it describes,
|
|
34
|
+
* and a finding stands only when it says yes to every one.
|
|
35
|
+
*/
|
|
36
|
+
appliesTo: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
37
|
+
/**
|
|
38
|
+
* Where the rule does not apply: Jev is asked of each whether all the code
|
|
39
|
+
* that breaks the rule is as it describes, and a yes to any drops the finding.
|
|
40
|
+
*/
|
|
41
|
+
excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
20
42
|
});
|
|
21
43
|
|
|
22
44
|
/** Before 0.7, a rule's examples were `reference` and `avoid`: read as `must` and `never`. */
|
|
@@ -51,6 +73,9 @@ export const Rule = Schema.Struct({
|
|
|
51
73
|
);
|
|
52
74
|
export interface Rule extends Schema.Schema.Type<typeof Rule> {}
|
|
53
75
|
|
|
76
|
+
/** How a finding is reported: an error fails the run, a warning does not. */
|
|
77
|
+
export type Level = NonNullable<Rule["level"]>;
|
|
78
|
+
|
|
54
79
|
/** One of a rule's examples, under the word it is written with. */
|
|
55
80
|
export interface Example {
|
|
56
81
|
readonly word: "must" | "never" | "should" | "should not";
|
|
@@ -79,9 +104,35 @@ export type Rules = Readonly<Record<RuleId, Rule>>;
|
|
|
79
104
|
/** Rules inline, or a directory of Markdown rule files (see `markdown.ts`). */
|
|
80
105
|
export type RuleSource = Rules | string | URL;
|
|
81
106
|
|
|
107
|
+
/** A rule's setting in a config's `overrides`: `off`, a level, or a level and a threshold. */
|
|
108
|
+
const Override = Schema.Union([
|
|
109
|
+
Schema.Literals(["off", "error", "warning"]),
|
|
110
|
+
Schema.Struct({
|
|
111
|
+
level: Schema.optionalKey(Schema.Literals(["off", "error", "warning"])),
|
|
112
|
+
threshold: Schema.optionalKey(Schema.Finite),
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
115
|
+
export type Override = typeof Override.Type;
|
|
116
|
+
|
|
82
117
|
export const AdhereConfig = Schema.Struct({
|
|
118
|
+
/**
|
|
119
|
+
* The model id sent to typesafe. Defaults to "jev-latest"
|
|
120
|
+
*/
|
|
83
121
|
model: Schema.optionalKey(Schema.String),
|
|
122
|
+
/**
|
|
123
|
+
* The minimum confidence threshold for a positive match. Defaults to 0.8
|
|
124
|
+
*/
|
|
84
125
|
threshold: Schema.optionalKey(Schema.Finite),
|
|
126
|
+
/**
|
|
127
|
+
* Below this, Jev's probability that a file shows enough to decide a
|
|
128
|
+
* finding's rule adds a warning to the finding: check what the code relies
|
|
129
|
+
* on outside the file. Defaults to 0.6
|
|
130
|
+
*/
|
|
131
|
+
sufficiencyThreshold: Schema.optionalKey(Schema.Finite),
|
|
132
|
+
/**
|
|
133
|
+
* The presets to use for rule matching. Defaults to an empty array. Current presets
|
|
134
|
+
* include "effect" and "alchemy". Subcategories can be expanded by appending `/<subcategory>`.
|
|
135
|
+
*/
|
|
85
136
|
presets: Schema.Array(Schema.Literals(presetNames)).pipe(
|
|
86
137
|
Schema.withDecodingDefaultKey(Effect.succeed([])),
|
|
87
138
|
),
|
|
@@ -90,6 +141,24 @@ export const AdhereConfig = Schema.Struct({
|
|
|
90
141
|
* Unset: `.adhere/` when that directory exists, otherwise no rules.
|
|
91
142
|
*/
|
|
92
143
|
rules: Schema.optionalKey(Schema.Union([Schema.Record(Schema.String, Rule), Schema.String])),
|
|
144
|
+
/**
|
|
145
|
+
* Globs, relative to the working directory, of files no rule judges: what
|
|
146
|
+
* `--filter '!<glob>'` leaves out of one run, left out of every run.
|
|
147
|
+
*/
|
|
148
|
+
exclude: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
149
|
+
/**
|
|
150
|
+
* Settings for rules by the id a report names them with, a preset's with
|
|
151
|
+
* its preset first, as in `alchemy/providers/idempotent-delete`: `off`, a
|
|
152
|
+
* level, or `{ level, threshold }`. A preset's rule changes without its text
|
|
153
|
+
* being copied into `rules`.
|
|
154
|
+
*/
|
|
155
|
+
overrides: Schema.optionalKey(Schema.Record(Schema.String, Override)),
|
|
156
|
+
/**
|
|
157
|
+
* Whether Jev reads every comment, for rules about comments. By default
|
|
158
|
+
* comments are taken out, since a comment changes nothing a file does, but
|
|
159
|
+
* for the notes that say `@adhere`.
|
|
160
|
+
*/
|
|
161
|
+
includeComments: Schema.optionalKey(Schema.Boolean),
|
|
93
162
|
});
|
|
94
163
|
|
|
95
164
|
/** Where a repo keeps its rule files by default. The cache lives under it. */
|
|
@@ -120,17 +189,40 @@ export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
|
120
189
|
"vendor",
|
|
121
190
|
"e2e",
|
|
122
191
|
]);
|
|
123
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Directories whose files are tests, matched as whole path segments below the
|
|
194
|
+
* working directory, as skipped directories are. Only a rule that says `tests`
|
|
195
|
+
* judges them, or a `.test` or `.spec` file, such as `a.test.tsx`, anywhere.
|
|
196
|
+
*/
|
|
197
|
+
export const TEST_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
198
|
+
"test",
|
|
199
|
+
"tests",
|
|
200
|
+
"__tests__",
|
|
201
|
+
"testing",
|
|
202
|
+
"fixtures",
|
|
203
|
+
"fixture",
|
|
204
|
+
"__fixtures__",
|
|
205
|
+
"__mocks__",
|
|
206
|
+
]);
|
|
124
207
|
|
|
125
|
-
/** The shape a config file default-exports. */
|
|
208
|
+
/** The shape a config file default-exports: the schema's input, where `presets` is optional. */
|
|
126
209
|
export type Config = typeof AdhereConfig.Encoded;
|
|
127
210
|
|
|
128
211
|
export const defineConfig = (config: Config) => config;
|
|
129
212
|
|
|
130
213
|
/** A built-in rule set. Same shape as a config, minus `presets`. */
|
|
131
214
|
export interface Preset {
|
|
215
|
+
/**
|
|
216
|
+
* Override the model id used. Defaults to "jev-latest"
|
|
217
|
+
*/
|
|
132
218
|
readonly model?: string;
|
|
219
|
+
/**
|
|
220
|
+
* Override the threshold that determines a positive match. Defaults to 0.8
|
|
221
|
+
*/
|
|
133
222
|
readonly threshold?: number;
|
|
223
|
+
/**
|
|
224
|
+
* Per-rule overrides.
|
|
225
|
+
*/
|
|
134
226
|
readonly rules: RuleSource;
|
|
135
227
|
}
|
|
136
228
|
|
|
@@ -141,24 +233,33 @@ export type Loaded<T extends { readonly rules?: unknown }> = Omit<T, "rules"> &
|
|
|
141
233
|
|
|
142
234
|
export const DEFAULT_MODEL = "jev-latest";
|
|
143
235
|
export const DEFAULT_THRESHOLD = 0.8;
|
|
236
|
+
export const DEFAULT_SUFFICIENCY_THRESHOLD = 0.6;
|
|
144
237
|
|
|
145
238
|
/** A config with its presets folded in and every default applied. */
|
|
146
239
|
export interface ResolvedConfig {
|
|
147
240
|
readonly model: string;
|
|
148
241
|
readonly threshold: number;
|
|
242
|
+
/** Below this, a finding's sufficiency adds a warning to it. */
|
|
243
|
+
readonly sufficiencyThreshold: number;
|
|
149
244
|
readonly rules: Rules;
|
|
150
245
|
readonly scopedRules?: RuleSet;
|
|
151
246
|
/** Requests to Jev a minute, at most. Unset, requests go out as fast as they are made. */
|
|
152
247
|
readonly rpm?: number;
|
|
248
|
+
/** The config's globs of files no rule judges. */
|
|
249
|
+
readonly exclude?: ReadonlyArray<string>;
|
|
250
|
+
/** Whether Jev reads every comment; unset, only the `@adhere` notes. */
|
|
251
|
+
readonly includeComments?: boolean;
|
|
153
252
|
}
|
|
154
253
|
|
|
155
254
|
/** Command-line values that apply on top of the config file. */
|
|
156
|
-
export interface
|
|
255
|
+
export interface Flags {
|
|
157
256
|
readonly presets?: ReadonlyArray<PresetName>;
|
|
158
257
|
/** Replaces the config's global threshold. Per-rule thresholds still win. */
|
|
159
|
-
readonly threshold?: number;
|
|
258
|
+
readonly threshold?: number | undefined;
|
|
259
|
+
/** Replaces the config's sufficiency threshold. */
|
|
260
|
+
readonly sufficiencyThreshold?: number | undefined;
|
|
160
261
|
/** Throttles requests to Jev to at most this many a minute. */
|
|
161
|
-
readonly rpm?: number;
|
|
262
|
+
readonly rpm?: number | undefined;
|
|
162
263
|
}
|
|
163
264
|
|
|
164
265
|
/**
|
|
@@ -167,10 +268,10 @@ export interface Overrides {
|
|
|
167
268
|
* the whole preset already holds its rules.
|
|
168
269
|
*/
|
|
169
270
|
export const presetsOf = (
|
|
170
|
-
config: Pick<AdhereConfig, "presets">,
|
|
171
|
-
|
|
271
|
+
config: Pick<typeof AdhereConfig.Type, "presets">,
|
|
272
|
+
flags: Flags,
|
|
172
273
|
): ReadonlyArray<PresetName> => {
|
|
173
|
-
const named = [...new Set([...(
|
|
274
|
+
const named = [...new Set([...(flags.presets ?? []), ...config.presets])];
|
|
174
275
|
return named.filter((name) => {
|
|
175
276
|
const [whole] = name.split("/");
|
|
176
277
|
return whole === name || !named.some((other) => other === whole);
|
|
@@ -183,11 +284,11 @@ export const presetsOf = (
|
|
|
183
284
|
* rule with the same id.
|
|
184
285
|
*/
|
|
185
286
|
export const resolveConfig = (
|
|
186
|
-
config: Loaded<AdhereConfig>,
|
|
187
|
-
|
|
287
|
+
config: Loaded<typeof AdhereConfig.Type>,
|
|
288
|
+
flags: Flags = {},
|
|
188
289
|
registry: Readonly<Partial<Record<PresetName, Loaded<Preset>>>> = {},
|
|
189
290
|
): ResolvedConfig => {
|
|
190
|
-
const applied = presetsOf(config,
|
|
291
|
+
const applied = presetsOf(config, flags).flatMap((name) => {
|
|
191
292
|
const preset = registry[name];
|
|
192
293
|
return preset === undefined ? [] : [preset];
|
|
193
294
|
});
|
|
@@ -195,9 +296,13 @@ export const resolveConfig = (
|
|
|
195
296
|
applied.map((preset) => preset[key]).findLast((value) => value !== undefined);
|
|
196
297
|
return {
|
|
197
298
|
model: config.model ?? last("model") ?? DEFAULT_MODEL,
|
|
198
|
-
threshold:
|
|
299
|
+
threshold: flags.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_THRESHOLD,
|
|
300
|
+
sufficiencyThreshold:
|
|
301
|
+
flags.sufficiencyThreshold ?? config.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD,
|
|
199
302
|
rules: Object.assign({}, ...applied.map((preset) => preset.rules), config.rules),
|
|
200
|
-
...(
|
|
303
|
+
...(flags.rpm === undefined ? {} : { rpm: flags.rpm }),
|
|
304
|
+
...(config.exclude === undefined ? {} : { exclude: config.exclude }),
|
|
305
|
+
...(config.includeComments === undefined ? {} : { includeComments: config.includeComments }),
|
|
201
306
|
};
|
|
202
307
|
};
|
|
203
308
|
|
|
@@ -206,7 +311,14 @@ export class ConfigUnavailable extends Schema.TaggedError<ConfigUnavailable>()(
|
|
|
206
311
|
{ message: Schema.String },
|
|
207
312
|
) {}
|
|
208
313
|
|
|
209
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Decodes an in-memory value. @adhere The file-reading caller loadConfigFile
|
|
316
|
+
* in services/AdhereConfig.ts prefixes these errors with the config path;
|
|
317
|
+
* verified in its source and by CLI probes at all three config paths, 2026-09-26.
|
|
318
|
+
*/
|
|
319
|
+
export const decodeConfig = (
|
|
320
|
+
value: unknown,
|
|
321
|
+
): Effect.Effect<typeof AdhereConfig.Type, ConfigUnavailable> =>
|
|
210
322
|
Schema.decodeUnknownEffect(AdhereConfig)(value).pipe(
|
|
211
323
|
Effect.mapError((problem) =>
|
|
212
324
|
ConfigUnavailable.make({
|
package/src/contradictions.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
),
|
package/src/excerpt.ts
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
import { scan } from "#highlight.ts";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The lines a finding shows: the statement its line is in, such as the
|
|
5
|
-
* function around it, and a few lines on either side of the line.
|
|
6
|
-
*/
|
|
3
|
+
/** The lines a finding shows: the section of the file it is in. */
|
|
7
4
|
export interface Excerpt {
|
|
8
5
|
/** The number of the first line, counting from 1. */
|
|
9
6
|
readonly start: number;
|
|
10
7
|
readonly lines: ReadonlyArray<string>;
|
|
11
8
|
}
|
|
12
9
|
|
|
13
|
-
/** How far an excerpt reaches on either side of the finding's line, in whole statements. */
|
|
14
|
-
const AROUND = 3;
|
|
15
|
-
/** The most lines a statement can have and still be shown whole. */
|
|
16
|
-
const WHOLE = 30;
|
|
17
|
-
|
|
18
10
|
/** Lines of the code, first to last, counting from 1. */
|
|
19
|
-
interface Range {
|
|
11
|
+
export interface Range {
|
|
20
12
|
readonly first: number;
|
|
21
13
|
readonly last: number;
|
|
22
14
|
}
|
|
@@ -48,19 +40,13 @@ const ROLES: Readonly<Record<string, Piece["role"]>> = {
|
|
|
48
40
|
",": "separator",
|
|
49
41
|
};
|
|
50
42
|
|
|
51
|
-
/**
|
|
52
|
-
* The code as pieces, without whitespace and comments, and the comments and
|
|
53
|
-
* literals that span lines: a line that starts inside one does not read as
|
|
54
|
-
* code on its own.
|
|
55
|
-
*/
|
|
43
|
+
/** The code as pieces, without whitespace and comments. */
|
|
56
44
|
const piecesOf = (code: string) => {
|
|
57
45
|
const pieces: Array<Piece> = [];
|
|
58
|
-
const spans: Array<Range> = [];
|
|
59
46
|
let line = 1;
|
|
60
47
|
let spaced = true;
|
|
61
48
|
for (const { text, kind } of scan(code)) {
|
|
62
49
|
const end = line + text.split("\n").length - 1;
|
|
63
|
-
if (kind !== undefined && end > line) spans.push({ first: line, last: end });
|
|
64
50
|
if (kind === "comment" || text.trim().length === 0) {
|
|
65
51
|
spaced = true;
|
|
66
52
|
} else if (kind !== undefined || /^[\w$]/.test(text)) {
|
|
@@ -75,7 +61,7 @@ const piecesOf = (code: string) => {
|
|
|
75
61
|
}
|
|
76
62
|
line = end;
|
|
77
63
|
}
|
|
78
|
-
return { pieces
|
|
64
|
+
return { pieces };
|
|
79
65
|
};
|
|
80
66
|
|
|
81
67
|
/**
|
|
@@ -209,47 +195,81 @@ const statementsAround = (
|
|
|
209
195
|
];
|
|
210
196
|
};
|
|
211
197
|
|
|
198
|
+
/** The most lines a section holds, unless one statement with no group to split is longer. */
|
|
199
|
+
const SECTION = 30;
|
|
200
|
+
|
|
212
201
|
/**
|
|
213
|
-
* The
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* `line` itself is in can show in part: a long function's first line, above
|
|
222
|
-
* the statement of its body. It ends at code, since a comment at the end
|
|
223
|
-
* would be about code the excerpt leaves out.
|
|
202
|
+
* The file in sections, each ending where a statement does: its top-level
|
|
203
|
+
* statements, such as its imports, constants, and functions, packed in
|
|
204
|
+
* order into sections of at most `SECTION` lines, a decorator with what it
|
|
205
|
+
* decorates. A statement longer than that splits into the statements of its
|
|
206
|
+
* largest group, such as a class's methods or a component's body, the lines
|
|
207
|
+
* around that group going with the first and last of them. Every line is in
|
|
208
|
+
* one section: the comments and blank lines above a statement go with it,
|
|
209
|
+
* and the last section runs to the end.
|
|
224
210
|
*/
|
|
225
|
-
export const
|
|
226
|
-
const { pieces
|
|
211
|
+
export const sectionsOf = (lines: ReadonlyArray<string>): ReadonlyArray<Range> => {
|
|
212
|
+
const { pieces } = piecesOf(lines.join("\n"));
|
|
227
213
|
const closes = closesOf(pieces);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
214
|
+
const lineOf = (index: number) => pieces[index]?.line ?? 0;
|
|
215
|
+
/** Statements as ranges of at most `SECTION` lines where they can be split. */
|
|
216
|
+
const units = (statements: ReadonlyArray<Statement>): ReadonlyArray<Range> =>
|
|
217
|
+
statements.flatMap((statement): ReadonlyArray<Range> => {
|
|
218
|
+
if (statement.last - statement.first < SECTION) return [statement];
|
|
219
|
+
const largest = statement.groups.reduce<readonly [number, number] | undefined>(
|
|
220
|
+
(best, group) =>
|
|
221
|
+
best === undefined ||
|
|
222
|
+
lineOf(group[1]) - lineOf(group[0]) > lineOf(best[1]) - lineOf(best[0])
|
|
223
|
+
? group
|
|
224
|
+
: best,
|
|
225
|
+
undefined,
|
|
226
|
+
);
|
|
227
|
+
const inner =
|
|
228
|
+
largest === undefined
|
|
229
|
+
? []
|
|
230
|
+
: units(statementsIn(pieces, closes, largest[0] + 1, largest[1]));
|
|
231
|
+
const [head, tail] = [inner[0], inner.at(-1)];
|
|
232
|
+
if (head === undefined || tail === undefined || inner.length < 2) return [statement];
|
|
233
|
+
return [
|
|
234
|
+
{ first: statement.first, last: head.last },
|
|
235
|
+
...inner.slice(1, -1),
|
|
236
|
+
{ first: tail.first, last: statement.last },
|
|
237
|
+
];
|
|
238
|
+
});
|
|
239
|
+
const sections: Array<Range> = [];
|
|
240
|
+
/** A decorator, such as `@Command({…})`, waiting for the declaration it decorates. */
|
|
241
|
+
let decorator: Range | undefined;
|
|
242
|
+
for (const found of units(statementsIn(pieces, closes, 0, pieces.length))) {
|
|
243
|
+
if ((lines[found.first - 1] ?? "").trimStart().startsWith("@")) {
|
|
244
|
+
decorator = { first: decorator?.first ?? found.first, last: found.last };
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const unit = { first: decorator?.first ?? found.first, last: found.last };
|
|
248
|
+
decorator = undefined;
|
|
249
|
+
const previous = sections.at(-1);
|
|
250
|
+
if (previous !== undefined && unit.last - previous.first < SECTION) {
|
|
251
|
+
sections[sections.length - 1] = { first: previous.first, last: unit.last };
|
|
252
|
+
} else {
|
|
253
|
+
sections.push({ first: unit.first, last: unit.last });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (decorator !== undefined) sections.push(decorator);
|
|
257
|
+
// A file of comments alone, with no statement, is one section.
|
|
258
|
+
if (sections.length === 0) return lines.length === 0 ? [] : [{ first: 1, last: lines.length }];
|
|
259
|
+
// Each section starts where the one before it ends, and the last one ends with the file.
|
|
260
|
+
return sections.flatMap((section, index): ReadonlyArray<Range> => {
|
|
261
|
+
const first = (sections[index - 1]?.last ?? 0) + 1;
|
|
262
|
+
const last = index === sections.length - 1 ? lines.length : section.last;
|
|
263
|
+
return last < first ? [] : [{ first, last }];
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The statement that starts on `line`, such as a property, a function, or a
|
|
269
|
+
* call that opens there: the outermost statement around `line` whose first
|
|
270
|
+
* line it is. None when no statement starts on `line`.
|
|
271
|
+
*/
|
|
272
|
+
export const statementFrom = (lines: ReadonlyArray<string>, line: number): Range | undefined => {
|
|
273
|
+
const { pieces } = piecesOf(lines.join("\n"));
|
|
274
|
+
return statementsAround(pieces, closesOf(pieces), line).find((range) => range.first === line);
|
|
255
275
|
};
|
package/src/highlight.ts
CHANGED
|
@@ -30,6 +30,8 @@ const CLASSES = ["comment", "string", "constant"] as const;
|
|
|
30
30
|
/** One group per token class, tried in order at each position. A template literal is read apart. */
|
|
31
31
|
const TOKEN = new RegExp(
|
|
32
32
|
[
|
|
33
|
+
// A URL outside a string is JSX text, as in `<a>https://example.com</a>`, and its `//` no comment.
|
|
34
|
+
/(?<url>[a-z][\w+.-]*:\/\/[^\s<>{}"'`]*)/,
|
|
33
35
|
/(?<comment>\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))/,
|
|
34
36
|
/(?<string>"(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'?)/,
|
|
35
37
|
/(?<constant>0[box][\da-f_]+n?|\d[\d_]*(?:\.\d[\d_]*)?(?:e[+-]?\d+)?n?)/,
|
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
|
});
|