@drkmttr/adhere 0.7.3 → 0.9.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 +177 -37
- package/package.json +12 -12
- package/presets/alchemy/apis/match-remote-errors-by-tag.md +15 -0
- package/presets/alchemy/apis/schemas-at-trust-boundaries.md +18 -0
- package/presets/alchemy/data/commit-migrations-then-deploy.md +15 -0
- package/presets/alchemy/data/hyperdrive-direct-origin.md +14 -0
- package/presets/alchemy/data/migrations-use-direct-url.md +11 -0
- package/presets/alchemy/data/never-disable-tls-verification.md +16 -0
- package/presets/alchemy/data/parameterized-sql.md +13 -0
- package/presets/alchemy/data/private-endpoint-at-runtime.md +12 -0
- package/presets/alchemy/data/single-migration-owner.md +13 -0
- package/presets/alchemy/durable/idempotent-event-handlers.md +15 -0
- package/presets/alchemy/durable/io-inside-steps.md +17 -0
- package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +15 -0
- package/presets/alchemy/durable/stable-step-names.md +11 -0
- package/presets/alchemy/durable/webhook-state-is-truth.md +14 -0
- package/presets/alchemy/providers/diff-guards-unresolved-props.md +16 -0
- package/presets/alchemy/providers/idempotent-delete.md +14 -0
- package/presets/alchemy/providers/lazy-layer-construction.md +14 -0
- package/presets/alchemy/providers/reconcile-from-observed-state.md +20 -0
- package/presets/alchemy/resources/anchor-paths-with-import-meta.md +14 -0
- package/presets/alchemy/resources/idempotent-actions-and-seeds.md +19 -0
- package/presets/alchemy/resources/previews-reference-shared-resources.md +15 -0
- package/presets/alchemy/resources/protect-irreplaceable-data.md +12 -0
- package/presets/alchemy/resources/remote-state-for-ci.md +22 -0
- package/presets/alchemy/resources/stable-logical-ids.md +13 -0
- package/presets/alchemy/resources/stage-unique-physical-names.md +15 -0
- package/presets/alchemy/runtime/bind-tag-not-implementation.md +27 -0
- package/presets/alchemy/runtime/deterministic-config-defaults.md +13 -0
- package/presets/alchemy/runtime/disposables-in-request-scope.md +27 -0
- package/presets/alchemy/runtime/lambda-no-post-response-work.md +19 -0
- package/presets/alchemy/runtime/no-per-request-work-at-construction.md +24 -0
- package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +31 -0
- package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +13 -0
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +15 -0
- package/presets/alchemy/secrets/none-in-browser-env.md +15 -0
- package/presets/alchemy/secrets/redacted-until-use.md +19 -0
- package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +17 -0
- package/presets/alchemy/security/authorize-public-endpoints.md +20 -0
- package/presets/alchemy/security/github-secret-not-variable.md +12 -0
- package/presets/alchemy/security/identity-from-verified-session.md +14 -0
- package/presets/alchemy/security/keep-auth-protections-on.md +19 -0
- package/presets/alchemy/security/least-privilege-bindings.md +13 -0
- package/presets/alchemy/security/least-privilege-ci-credentials.md +24 -0
- package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +15 -0
- package/presets/effect/basics/external-calls-are-resilient.md +3 -3
- package/presets/effect/config/tests-provide-values-directly.md +3 -3
- package/presets/effect/config/validate-with-schema.md +13 -2
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/services/dependencies-through-layers.md +42 -0
- package/presets/effect/services/operations-have-no-requirements.md +46 -0
- package/presets/effect/services/test-layers-are-in-memory.md +3 -3
- package/presets/effect/testing/test-clock-for-time.md +1 -1
- package/skills/adhere/SKILL.md +3 -2
- package/src/cli.ts +42 -16
- package/src/config.ts +12 -2
- package/src/excerpt.ts +255 -0
- package/src/highlight.ts +82 -20
- package/src/init.ts +10 -4
- package/src/main.ts +35 -4
- package/src/mechanical.ts +89 -0
- package/src/presets.ts +45 -8
- package/src/rules.ts +2 -0
- package/src/services/AdhereConfig.ts +18 -20
- package/src/services/AuditCache.ts +272 -23
- package/src/services/Jev.http.ts +66 -12
- package/src/services/Jev.ts +66 -6
- package/src/services/SourceWalker.ts +11 -4
- package/src/wording.ts +90 -0
- package/src/workflows/audit.ts +169 -26
- package/src/workflows/format.ts +35 -18
- package/presets/effect/basics/fn-for-named-effects.md +0 -19
- package/presets/effect/cli/handlers-delegate.md +0 -24
- package/presets/effect/data/decode-json-with-schema.md +0 -18
- package/presets/effect/data/records-are-schema-classes.md +0 -23
- package/presets/effect/errors/domain-errors-are-tagged.md +0 -19
- package/presets/effect/platform/os-access-through-platform-services.md +0 -26
- package/presets/effect/services/methods-have-no-requirements.md +0 -20
- package/presets/effect/services/provide-at-entry.md +0 -23
- package/presets/effect/testing/test-random.md +0 -22
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: A test that depends on time must use TestClock
|
|
2
|
+
description: A test that depends on time must use TestClock, never a real sleep or it.live, unless the test needs real time.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
```ts must
|
package/skills/adhere/SKILL.md
CHANGED
|
@@ -103,14 +103,15 @@ export default {
|
|
|
103
103
|
} satisfies Config;
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
Commit `.adhere/cache/` with the code, so the team and CI reuse its judgments
|
|
107
|
+
instead of paying for them again. Uncached files need a TypeSafe AI API
|
|
107
108
|
key: one saved by `adhere login`, or `TYPESAFE_API_KEY`, which takes
|
|
108
109
|
precedence; in this org it comes from SOPS via `just shell`.
|
|
109
110
|
|
|
110
111
|
## 5. Run and calibrate
|
|
111
112
|
|
|
112
113
|
```sh
|
|
113
|
-
adhere validate # decodes the config and rules,
|
|
114
|
+
adhere validate # decodes the config and rules; reports their wording, rules a linter could check, and contradictions
|
|
114
115
|
adhere lint # or: adhere lint --preset effect
|
|
115
116
|
adhere lint --threshold 0.9 # re-reads the cache, sends nothing
|
|
116
117
|
```
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
executeAudit,
|
|
9
9
|
type FileDone,
|
|
10
10
|
planAudit,
|
|
11
|
+
pruneCache,
|
|
11
12
|
render,
|
|
12
13
|
} from "#workflows/audit.ts";
|
|
13
14
|
import {
|
|
@@ -20,16 +21,20 @@ import {
|
|
|
20
21
|
import type { Overrides } from "#config.ts";
|
|
21
22
|
import { findContradictions, formatContradictions } from "#contradictions.ts";
|
|
22
23
|
import { initProject } from "#init.ts";
|
|
24
|
+
import { formatLinterCheck, tallyProjectRules } from "#mechanical.ts";
|
|
23
25
|
import { type PresetName, presetNames } from "#presets.ts";
|
|
24
26
|
import { globalRuleSet } from "#rules.ts";
|
|
27
|
+
import { formatStrays, straysAmong } from "#wording.ts";
|
|
25
28
|
import {
|
|
26
29
|
Console,
|
|
27
30
|
Effect,
|
|
28
31
|
Exit,
|
|
29
32
|
Layer,
|
|
33
|
+
LogLevel,
|
|
30
34
|
Option,
|
|
31
35
|
Path,
|
|
32
36
|
Redacted,
|
|
37
|
+
References,
|
|
33
38
|
Runtime,
|
|
34
39
|
Schema,
|
|
35
40
|
Stdio,
|
|
@@ -58,7 +63,9 @@ class ContradictionsReported extends Schema.TaggedError<ContradictionsReported>(
|
|
|
58
63
|
|
|
59
64
|
const preset = Flag.choice("preset", presetNames).pipe(
|
|
60
65
|
Flag.optional,
|
|
61
|
-
Flag.withDescription(
|
|
66
|
+
Flag.withDescription(
|
|
67
|
+
"Add a built-in rule set, or one of its topics, such as effect/basics. With a preset, .adhere/config.ts is optional.",
|
|
68
|
+
),
|
|
62
69
|
);
|
|
63
70
|
|
|
64
71
|
const threshold = Flag.float("threshold").pipe(
|
|
@@ -118,8 +125,9 @@ const toStderr = (text: string) =>
|
|
|
118
125
|
* anything else it stays quiet, so a log gets the plan and the report rather
|
|
119
126
|
* than a line per file. A failed run keeps its last count on screen.
|
|
120
127
|
*/
|
|
121
|
-
const counterFor = (plan: AuditPlan) => {
|
|
122
|
-
|
|
128
|
+
const counterFor = (plan: AuditPlan, logging: LogLevel.LogLevel) => {
|
|
129
|
+
// Log lines at debug or below would land between redraws and garble the counter.
|
|
130
|
+
const live = process.stderr.isTTY === true && LogLevel.isGreaterThan(logging, "Debug");
|
|
123
131
|
let progress: Progress = { files: 0, requests: 0, findings: 0 };
|
|
124
132
|
const draw = () => (live ? toStderr(`\r\u001b[2K${progressLine(progress, plan)}`) : Effect.void);
|
|
125
133
|
return {
|
|
@@ -170,11 +178,13 @@ export const lintCommand = Command.make(
|
|
|
170
178
|
return yield* Cancelled.make({});
|
|
171
179
|
}
|
|
172
180
|
}
|
|
173
|
-
const counter = counterFor(plan);
|
|
181
|
+
const counter = counterFor(plan, yield* References.MinimumLogLevel);
|
|
174
182
|
yield* counter.start;
|
|
175
183
|
const result = yield* executeAudit(plan, counter.update).pipe(
|
|
176
184
|
Effect.onExit((exit) => counter.finish(Exit.isFailure(exit))),
|
|
177
185
|
);
|
|
186
|
+
// Only a run that read every file knows what the cache still needs.
|
|
187
|
+
if (input.filter.length === 0) yield* pruneCache(plan);
|
|
178
188
|
const color = yield* stdio.stdoutIsTerminal;
|
|
179
189
|
const path = yield* Path.Path;
|
|
180
190
|
// One write: separate Console.log calls have interleaved out of order here.
|
|
@@ -201,31 +211,44 @@ export const lintCommand = Command.make(
|
|
|
201
211
|
|
|
202
212
|
/** Jev, with the key `lint` uses, and the config it reads the model and threshold from. */
|
|
203
213
|
export const validateLayer = (overrides: Overrides) =>
|
|
204
|
-
|
|
205
|
-
Layer.provide([FetchHttpClient.layer, CredentialsLive]),
|
|
206
|
-
|
|
207
|
-
);
|
|
214
|
+
Layer.merge(
|
|
215
|
+
JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
|
|
216
|
+
AuditCacheLive,
|
|
217
|
+
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
|
|
208
218
|
|
|
209
219
|
/**
|
|
210
|
-
* `adhere validate`: everything `lint` loads, then
|
|
211
|
-
*
|
|
212
|
-
*
|
|
220
|
+
* `adhere validate`: everything `lint` loads, then how each rule's wording
|
|
221
|
+
* differs from the README's rule writing tips, then the linter check's tallies
|
|
222
|
+
* from lint, then Jev on whether any two rules that apply to the same files
|
|
223
|
+
* contradict. Only a contradiction fails the run: the tips and the linter
|
|
224
|
+
* check are advice. A config, rule file, or preset that does not decode
|
|
225
|
+
* refuses here the way it would refuse `lint`.
|
|
213
226
|
*/
|
|
214
227
|
export const validateCommand = Command.make("validate", { preset }, () =>
|
|
215
228
|
Effect.gen(function* () {
|
|
216
229
|
const config = yield* AdhereConfig;
|
|
217
230
|
const path = yield* Path.Path;
|
|
218
|
-
const
|
|
219
|
-
const
|
|
231
|
+
const root = path.resolve();
|
|
232
|
+
const entries = config.scopedRules ?? globalRuleSet(config.rules, root);
|
|
220
233
|
const loaded = `${entries.length} ${entries.length === 1 ? "rule" : "rules"} loaded.`;
|
|
221
|
-
yield*
|
|
234
|
+
const tallied = yield* tallyProjectRules(entries, config.model);
|
|
235
|
+
// The wording and the tallies need no request, so they show before Jev is asked anything.
|
|
236
|
+
yield* Console.log(
|
|
237
|
+
[
|
|
238
|
+
loaded,
|
|
239
|
+
...formatStrays(straysAmong(entries), root),
|
|
240
|
+
...formatLinterCheck(tallied, root),
|
|
241
|
+
].join("\n"),
|
|
242
|
+
);
|
|
243
|
+
const contradictions = yield* findContradictions(entries, config.threshold);
|
|
244
|
+
yield* Console.log(formatContradictions(contradictions));
|
|
222
245
|
if (contradictions.length > 0) {
|
|
223
246
|
return yield* ContradictionsReported.make({ count: contradictions.length });
|
|
224
247
|
}
|
|
225
248
|
}),
|
|
226
249
|
).pipe(
|
|
227
250
|
Command.withDescription(
|
|
228
|
-
"Load the config, rule files, and presets the way lint does, then ask Jev whether any two rules that apply to the same files contradict each other.",
|
|
251
|
+
"Load the config, rule files, and presets the way lint does, check each rule's wording against the README's rule writing tips, report the rules lint's linter check flags, then ask Jev whether any two rules that apply to the same files contradict each other.",
|
|
229
252
|
),
|
|
230
253
|
Command.provide((input) => validateLayer({ presets: chosenPresets(input.preset) })),
|
|
231
254
|
);
|
|
@@ -312,7 +335,10 @@ export const cli = Command.make("adhere").pipe(
|
|
|
312
335
|
Command.withDescription("Lint a repository against the rules in .adhere/, judged by Jev."),
|
|
313
336
|
Command.withExamples([
|
|
314
337
|
{ command: "adhere init", description: "Scaffold .adhere/config.ts and two example rules" },
|
|
315
|
-
{
|
|
338
|
+
{
|
|
339
|
+
command: "adhere validate",
|
|
340
|
+
description: "Check the rules' wording, and ask Jev whether any contradict",
|
|
341
|
+
},
|
|
316
342
|
{ command: "adhere login", description: "Save your TypeSafe AI API key for later runs" },
|
|
317
343
|
{ command: "adhere lint", description: "Audit the working directory" },
|
|
318
344
|
{
|
package/src/config.ts
CHANGED
|
@@ -161,11 +161,21 @@ export interface Overrides {
|
|
|
161
161
|
readonly rpm?: number;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* The presets a run applies, command-line ones first, each once. A topic such
|
|
166
|
+
* as `effect/basics` is left out when its whole preset is named too, since
|
|
167
|
+
* the whole preset already holds its rules.
|
|
168
|
+
*/
|
|
165
169
|
export const presetsOf = (
|
|
166
170
|
config: Pick<AdhereConfig, "presets">,
|
|
167
171
|
overrides: Overrides,
|
|
168
|
-
): ReadonlyArray<PresetName> =>
|
|
172
|
+
): ReadonlyArray<PresetName> => {
|
|
173
|
+
const named = [...new Set([...(overrides.presets ?? []), ...config.presets])];
|
|
174
|
+
return named.filter((name) => {
|
|
175
|
+
const [whole] = name.split("/");
|
|
176
|
+
return whole === name || !named.some((other) => other === whole);
|
|
177
|
+
});
|
|
178
|
+
};
|
|
169
179
|
|
|
170
180
|
/**
|
|
171
181
|
* Precedence, highest first: command line, config file, presets in order
|
package/src/excerpt.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { scan } from "#highlight.ts";
|
|
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
|
+
*/
|
|
7
|
+
export interface Excerpt {
|
|
8
|
+
/** The number of the first line, counting from 1. */
|
|
9
|
+
readonly start: number;
|
|
10
|
+
readonly lines: ReadonlyArray<string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
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
|
+
/** Lines of the code, first to last, counting from 1. */
|
|
19
|
+
interface Range {
|
|
20
|
+
readonly first: number;
|
|
21
|
+
readonly last: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const holds = (range: Range, line: number): boolean => range.first <= line && line <= range.last;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A token that shapes the code: a bracket, a separator, an operator, or a
|
|
28
|
+
* value, such as a name, a keyword, or a literal. Only a template literal
|
|
29
|
+
* ends on a later line than the one it starts on.
|
|
30
|
+
*/
|
|
31
|
+
interface Piece {
|
|
32
|
+
readonly text: string;
|
|
33
|
+
readonly role: "open" | "close" | "separator" | "operator" | "value";
|
|
34
|
+
readonly line: number;
|
|
35
|
+
readonly end: number;
|
|
36
|
+
/** Whether whitespace or a comment comes right before it. */
|
|
37
|
+
readonly spaced: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ROLES: Readonly<Record<string, Piece["role"]>> = {
|
|
41
|
+
"(": "open",
|
|
42
|
+
"[": "open",
|
|
43
|
+
"{": "open",
|
|
44
|
+
")": "close",
|
|
45
|
+
"]": "close",
|
|
46
|
+
"}": "close",
|
|
47
|
+
";": "separator",
|
|
48
|
+
",": "separator",
|
|
49
|
+
};
|
|
50
|
+
|
|
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
|
+
*/
|
|
56
|
+
const piecesOf = (code: string) => {
|
|
57
|
+
const pieces: Array<Piece> = [];
|
|
58
|
+
const spans: Array<Range> = [];
|
|
59
|
+
let line = 1;
|
|
60
|
+
let spaced = true;
|
|
61
|
+
for (const { text, kind } of scan(code)) {
|
|
62
|
+
const end = line + text.split("\n").length - 1;
|
|
63
|
+
if (kind !== undefined && end > line) spans.push({ first: line, last: end });
|
|
64
|
+
if (kind === "comment" || text.trim().length === 0) {
|
|
65
|
+
spaced = true;
|
|
66
|
+
} else if (kind !== undefined || /^[\w$]/.test(text)) {
|
|
67
|
+
pieces.push({ text, role: "value", line, end, spaced });
|
|
68
|
+
spaced = false;
|
|
69
|
+
} else {
|
|
70
|
+
// A run of punctuation, like `});` or `=>{`: each bracket and separator is a piece.
|
|
71
|
+
for (const part of text.match(/[()[\]{};,]|[^()[\]{};,]+/g) ?? []) {
|
|
72
|
+
pieces.push({ text: part, role: ROLES[part] ?? "operator", line, end, spaced });
|
|
73
|
+
spaced = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
line = end;
|
|
77
|
+
}
|
|
78
|
+
return { pieces, spans };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Where each group closes: the index of the piece that opens it, mapped to
|
|
83
|
+
* the index of the one that closes it. Type arguments broken over lines, from
|
|
84
|
+
* a `Type<` that ends a line to a `>` that starts one, are a group too, so
|
|
85
|
+
* their commas do not end a statement; a `<` that no such `>` closes was a
|
|
86
|
+
* comparison. A bracket left open runs to the end.
|
|
87
|
+
*/
|
|
88
|
+
const closesOf = (pieces: ReadonlyArray<Piece>): ReadonlyMap<number, number> => {
|
|
89
|
+
const closes = new Map<number, number>();
|
|
90
|
+
const opened: Array<number> = [];
|
|
91
|
+
const isAngle = (index: number | undefined): index is number =>
|
|
92
|
+
index !== undefined && pieces[index]?.text === "<";
|
|
93
|
+
pieces.forEach((piece, index) => {
|
|
94
|
+
const before = pieces[index - 1];
|
|
95
|
+
const after = pieces[index + 1];
|
|
96
|
+
const top = opened.at(-1);
|
|
97
|
+
if (piece.role === "open") {
|
|
98
|
+
opened.push(index);
|
|
99
|
+
} else if (piece.text === "<") {
|
|
100
|
+
const endsLine = after === undefined || after.line > piece.line;
|
|
101
|
+
if (endsLine && !piece.spaced && before?.role === "value") opened.push(index);
|
|
102
|
+
} else if (piece.text === ">" && isAngle(top) && (before?.end ?? 0) < piece.line) {
|
|
103
|
+
opened.pop();
|
|
104
|
+
closes.set(top, index);
|
|
105
|
+
} else if (piece.role === "close" || piece.text === ";") {
|
|
106
|
+
while (isAngle(opened.at(-1))) opened.pop();
|
|
107
|
+
const opener = piece.role === "close" ? opened.pop() : undefined;
|
|
108
|
+
if (opener !== undefined) closes.set(opener, index);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
for (const opener of opened) {
|
|
112
|
+
if (!isAngle(opener)) closes.set(opener, pieces.length - 1);
|
|
113
|
+
}
|
|
114
|
+
return closes;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Operators that leave an expression open at the end of a line: `=`, `=>`, `&&`, `.`. */
|
|
118
|
+
const LEAVES_OPEN = /[-+*/%&|^<>=?:.]$/;
|
|
119
|
+
/** Operators that carry the expression above on at the start of a line: `.pipe(`, `? a`, `| B`. */
|
|
120
|
+
const CARRIES_ON = /^[-+*/%&|^>=?:.]/;
|
|
121
|
+
/** Words that need more after them at the end of a line. */
|
|
122
|
+
const OPEN_WORDS = new Set(["as", "await", "extends", "implements", "keyof", "new", "satisfies"]);
|
|
123
|
+
/** Words that go on with the statement above at the start of a line. */
|
|
124
|
+
const ON_WORDS = new Set(["as", "catch", "else", "extends", "finally", "implements", "satisfies"]);
|
|
125
|
+
|
|
126
|
+
const isStep = (text: string): boolean => text === "++" || text === "--";
|
|
127
|
+
|
|
128
|
+
const leavesOpen = (piece: Piece): boolean =>
|
|
129
|
+
piece.role === "operator"
|
|
130
|
+
? LEAVES_OPEN.test(piece.text) && !isStep(piece.text)
|
|
131
|
+
: piece.role === "value" && OPEN_WORDS.has(piece.text);
|
|
132
|
+
|
|
133
|
+
const carriesOn = (piece: Piece): boolean =>
|
|
134
|
+
piece.role === "operator"
|
|
135
|
+
? CARRIES_ON.test(piece.text) && !isStep(piece.text)
|
|
136
|
+
: piece.role === "value" && ON_WORDS.has(piece.text);
|
|
137
|
+
|
|
138
|
+
/** A statement's lines, and the groups directly inside it, as the pieces that open and close each. */
|
|
139
|
+
interface Statement extends Range {
|
|
140
|
+
readonly groups: ReadonlyArray<readonly [number, number]>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The statements in the pieces from `from` up to `to`, each group inside one
|
|
145
|
+
* taken whole. A statement ends at a `;` or `,`, or at a line break that
|
|
146
|
+
* neither side carries across: about where JavaScript inserts a semicolon.
|
|
147
|
+
*/
|
|
148
|
+
const statementsIn = (
|
|
149
|
+
pieces: ReadonlyArray<Piece>,
|
|
150
|
+
closes: ReadonlyMap<number, number>,
|
|
151
|
+
from: number,
|
|
152
|
+
to: number,
|
|
153
|
+
): ReadonlyArray<Statement> => {
|
|
154
|
+
const statements: Array<Statement> = [];
|
|
155
|
+
let current:
|
|
156
|
+
| { first: number; last: number; groups: Array<readonly [number, number]> }
|
|
157
|
+
| undefined;
|
|
158
|
+
let open = false;
|
|
159
|
+
let index = from;
|
|
160
|
+
while (index < to) {
|
|
161
|
+
const piece = pieces[index];
|
|
162
|
+
if (piece === undefined) break;
|
|
163
|
+
if (current !== undefined && piece.line > current.last && !open && !carriesOn(piece)) {
|
|
164
|
+
statements.push(current);
|
|
165
|
+
current = undefined;
|
|
166
|
+
}
|
|
167
|
+
const close = Math.min(closes.get(index) ?? index, to - 1);
|
|
168
|
+
const end = pieces[close]?.end ?? piece.end;
|
|
169
|
+
current ??= { first: piece.line, last: end, groups: [] };
|
|
170
|
+
current.last = end;
|
|
171
|
+
if (close > index) current.groups.push([index, close]);
|
|
172
|
+
// A group's closing bracket leaves nothing open.
|
|
173
|
+
open = close === index && leavesOpen(piece);
|
|
174
|
+
if (piece.role === "separator") {
|
|
175
|
+
statements.push(current);
|
|
176
|
+
current = undefined;
|
|
177
|
+
}
|
|
178
|
+
index = close + 1;
|
|
179
|
+
}
|
|
180
|
+
if (current !== undefined) statements.push(current);
|
|
181
|
+
return statements;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The statements `line` is in, outermost first: the one at the top level,
|
|
186
|
+
* then the one around `line` inside the group of that statement it is in,
|
|
187
|
+
* and so on inward. A line where two statements meet is in both, together.
|
|
188
|
+
*/
|
|
189
|
+
const statementsAround = (
|
|
190
|
+
pieces: ReadonlyArray<Piece>,
|
|
191
|
+
closes: ReadonlyMap<number, number>,
|
|
192
|
+
line: number,
|
|
193
|
+
from = 0,
|
|
194
|
+
to = pieces.length,
|
|
195
|
+
): ReadonlyArray<Range> => {
|
|
196
|
+
const around = statementsIn(pieces, closes, from, to).filter((statement) =>
|
|
197
|
+
holds(statement, line),
|
|
198
|
+
);
|
|
199
|
+
const [outer, inner] = [around[0], around.at(-1)];
|
|
200
|
+
if (outer === undefined || inner === undefined) return [];
|
|
201
|
+
const group = around
|
|
202
|
+
.flatMap((statement) => statement.groups)
|
|
203
|
+
.find(
|
|
204
|
+
([open, close]) => (pieces[open]?.line ?? line) < line && line <= (pieces[close]?.line ?? 0),
|
|
205
|
+
);
|
|
206
|
+
return [
|
|
207
|
+
{ first: outer.first, last: inner.last },
|
|
208
|
+
...(group === undefined ? [] : statementsAround(pieces, closes, line, group[0] + 1, group[1])),
|
|
209
|
+
];
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The excerpt for a finding on `line`: the outermost statement around it
|
|
214
|
+
* with at most `WHOLE` lines, such as the function it is in, and up to
|
|
215
|
+
* `AROUND` lines on either side of `line`. A comment or template literal
|
|
216
|
+
* that spans lines counts as a statement inside the one it is in.
|
|
217
|
+
*
|
|
218
|
+
* Around `line`, the excerpt shows only whole statements, never the end of
|
|
219
|
+
* the one above or the start of the one below, so it cannot start partway
|
|
220
|
+
* through a comment, whose lines do not read as code on their own. What
|
|
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.
|
|
224
|
+
*/
|
|
225
|
+
export const excerptOf = (lines: ReadonlyArray<string>, line: number): Excerpt => {
|
|
226
|
+
const { pieces, spans } = piecesOf(lines.join("\n"));
|
|
227
|
+
const closes = closesOf(pieces);
|
|
228
|
+
/** What a line is in, outermost first. */
|
|
229
|
+
const around = (at: number): ReadonlyArray<Range> => [
|
|
230
|
+
...statementsAround(pieces, closes, at),
|
|
231
|
+
...spans.filter((span) => holds(span, at)),
|
|
232
|
+
];
|
|
233
|
+
const scope = around(line).find((range) => range.last - range.first < WHOLE);
|
|
234
|
+
const top = Math.max(1, Math.min(scope?.first ?? line, line - AROUND));
|
|
235
|
+
const bottom = Math.min(lines.length, Math.max(scope?.last ?? line, line + AROUND));
|
|
236
|
+
/** What a line is in that `line` is not, which an edge shows whole or not at all. */
|
|
237
|
+
const beside = (at: number) => around(at).filter((range) => !holds(range, line));
|
|
238
|
+
let first = Math.max(
|
|
239
|
+
top,
|
|
240
|
+
...beside(top)
|
|
241
|
+
.filter((range) => range.first < top)
|
|
242
|
+
.map((range) => range.last + 1),
|
|
243
|
+
);
|
|
244
|
+
let last = Math.min(
|
|
245
|
+
bottom,
|
|
246
|
+
...beside(bottom)
|
|
247
|
+
.filter((range) => range.last > bottom)
|
|
248
|
+
.map((range) => range.first - 1),
|
|
249
|
+
);
|
|
250
|
+
const isBlank = (at: number) => (lines[at - 1] ?? "").trim().length === 0;
|
|
251
|
+
const hasCode = (at: number) => pieces.some((piece) => piece.line <= at && at <= piece.end);
|
|
252
|
+
while (first < line && isBlank(first)) first += 1;
|
|
253
|
+
while (last > line && !hasCode(last)) last -= 1;
|
|
254
|
+
return { start: first, lines: lines.slice(first - 1, last) };
|
|
255
|
+
};
|
package/src/highlight.ts
CHANGED
|
@@ -27,11 +27,11 @@ const NOT_KEYS = words("default super this");
|
|
|
27
27
|
/** The groups that name their token's kind. */
|
|
28
28
|
const CLASSES = ["comment", "string", "constant"] as const;
|
|
29
29
|
|
|
30
|
-
/** One group per token class, tried in order at each position. */
|
|
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
33
|
/(?<comment>\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))/,
|
|
34
|
-
/(?<string>"(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'
|
|
34
|
+
/(?<string>"(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'?)/,
|
|
35
35
|
/(?<constant>0[box][\da-f_]+n?|\d[\d_]*(?:\.\d[\d_]*)?(?:e[+-]?\d+)?n?)/,
|
|
36
36
|
/(?<word>[a-z_$][\w$]*)/,
|
|
37
37
|
/(?<space>\s+)/,
|
|
@@ -71,36 +71,98 @@ const kindOf = (word: string, previous: string | undefined, isKey: boolean): Kin
|
|
|
71
71
|
return KEYWORDS.has(word) ? "keyword" : PRIMITIVES.has(word) ? "type" : undefined;
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
+
/** Whether a token is what the next one reads as before it: not whitespace or a comment. */
|
|
75
|
+
const counts = (token: Token): boolean => token.kind !== "comment" && token.text.trim().length > 0;
|
|
76
|
+
|
|
77
|
+
/** The token at `at`, after `previous`, the last one that counts. */
|
|
78
|
+
const tokenAt = (code: string, at: number, previous: string | undefined): Token | undefined => {
|
|
79
|
+
if (code[at] === "`") return { text: code.slice(at, templateEnd(code, at)), kind: "string" };
|
|
80
|
+
const regex = regexMayStart(previous) ? matchAt(REGEX, code, at) : null;
|
|
81
|
+
const match = regex ?? matchAt(TOKEN, code, at);
|
|
82
|
+
// `punctuation` matches any character the other groups do not.
|
|
83
|
+
if (match === null) return undefined;
|
|
84
|
+
const text = match[0];
|
|
85
|
+
const groups = match.groups ?? {};
|
|
86
|
+
const kind: Kind | undefined =
|
|
87
|
+
regex !== null
|
|
88
|
+
? "string"
|
|
89
|
+
: groups.word !== undefined
|
|
90
|
+
? kindOf(groups.word, previous, matchAt(KEY, code, at + text.length) !== null)
|
|
91
|
+
: CLASSES.find((name) => groups[name] !== undefined);
|
|
92
|
+
return kind === undefined ? { text } : { text, kind };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where the template literal that opens at `at` closes: past its closing
|
|
97
|
+
* backtick, or at the end of the code. The code in each `${…}` is read as
|
|
98
|
+
* tokens, so a brace or a template in it, as in `${xs.map((x) => `${x}`)}`,
|
|
99
|
+
* does not close the template early.
|
|
100
|
+
*/
|
|
101
|
+
const templateEnd = (code: string, at: number): number => {
|
|
102
|
+
let index = at + 1;
|
|
103
|
+
while (index < code.length && code[index] !== "`") {
|
|
104
|
+
if (code[index] === "\\") index += 2;
|
|
105
|
+
else if (code.startsWith("${", index)) index = substitutionEnd(code, index + 2);
|
|
106
|
+
else index += 1;
|
|
107
|
+
}
|
|
108
|
+
return Math.min(index + 1, code.length);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Where the code of a `${…}` that starts at `at` ends: past the brace that closes it. */
|
|
112
|
+
const substitutionEnd = (code: string, at: number): number => {
|
|
113
|
+
let depth = 0;
|
|
114
|
+
let previous: string | undefined;
|
|
115
|
+
let index = at;
|
|
116
|
+
while (index < code.length) {
|
|
117
|
+
const token = tokenAt(code, index, previous);
|
|
118
|
+
if (token === undefined) break;
|
|
119
|
+
// Braces are punctuation; one in a string or a nested template is text.
|
|
120
|
+
if (token.kind === undefined) {
|
|
121
|
+
for (let offset = 0; offset < token.text.length; offset += 1) {
|
|
122
|
+
if (token.text[offset] === "{") depth += 1;
|
|
123
|
+
else if (token.text[offset] === "}") {
|
|
124
|
+
if (depth === 0) return index + offset + 1;
|
|
125
|
+
depth -= 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (counts(token)) previous = token.text;
|
|
130
|
+
index += token.text.length;
|
|
131
|
+
}
|
|
132
|
+
return code.length;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The code's tokens, in order. A token that crosses a newline, like a block
|
|
137
|
+
* comment or a template literal, stays whole.
|
|
138
|
+
*/
|
|
139
|
+
export const scan = (code: string): ReadonlyArray<Token> => {
|
|
140
|
+
const tokens: Array<Token> = [];
|
|
141
|
+
let previous: string | undefined;
|
|
142
|
+
let at = 0;
|
|
143
|
+
while (at < code.length) {
|
|
144
|
+
const token = tokenAt(code, at, previous);
|
|
145
|
+
if (token === undefined) break;
|
|
146
|
+
tokens.push(token);
|
|
147
|
+
if (counts(token)) previous = token.text;
|
|
148
|
+
at += token.text.length;
|
|
149
|
+
}
|
|
150
|
+
return tokens;
|
|
151
|
+
};
|
|
152
|
+
|
|
74
153
|
/**
|
|
75
154
|
* The code's lines, each as tokens. A token that crosses a newline, like a
|
|
76
155
|
* block comment or a template literal, is cut into one token per line.
|
|
77
156
|
*/
|
|
78
157
|
export const tokenize = (code: string): ReadonlyArray<ReadonlyArray<Token>> => {
|
|
79
158
|
const lines: Array<Array<Token>> = [[]];
|
|
80
|
-
|
|
81
|
-
let previous: string | undefined;
|
|
82
|
-
let at = 0;
|
|
83
|
-
while (at < code.length) {
|
|
84
|
-
const regex = regexMayStart(previous) ? matchAt(REGEX, code, at) : null;
|
|
85
|
-
const match = regex ?? matchAt(TOKEN, code, at);
|
|
86
|
-
// `punctuation` matches any character the other groups do not.
|
|
87
|
-
if (match === null) break;
|
|
88
|
-
const text = match[0];
|
|
89
|
-
const groups = match.groups ?? {};
|
|
90
|
-
const kind: Kind | undefined =
|
|
91
|
-
regex !== null
|
|
92
|
-
? "string"
|
|
93
|
-
: groups.word !== undefined
|
|
94
|
-
? kindOf(groups.word, previous, matchAt(KEY, code, at + text.length) !== null)
|
|
95
|
-
: CLASSES.find((name) => groups[name] !== undefined);
|
|
159
|
+
for (const { text, kind } of scan(code)) {
|
|
96
160
|
for (const [index, piece] of text.split("\n").entries()) {
|
|
97
161
|
if (index > 0) lines.push([]);
|
|
98
162
|
if (piece.length > 0) {
|
|
99
163
|
lines.at(-1)?.push(kind === undefined ? { text: piece } : { text: piece, kind });
|
|
100
164
|
}
|
|
101
165
|
}
|
|
102
|
-
if (groups.comment === undefined && groups.space === undefined) previous = text;
|
|
103
|
-
at += text.length;
|
|
104
166
|
}
|
|
105
167
|
return lines;
|
|
106
168
|
};
|
package/src/init.ts
CHANGED
|
@@ -20,18 +20,24 @@ export default {
|
|
|
20
20
|
`;
|
|
21
21
|
|
|
22
22
|
const SMALL_FILES = `---
|
|
23
|
-
description: A file should be small and focused, with one primary responsibility.
|
|
23
|
+
description: A file should be small and focused, with one primary responsibility, and should not mix unrelated concerns.
|
|
24
24
|
threshold: 0.8
|
|
25
25
|
---
|
|
26
26
|
|
|
27
|
-
A guideline, so its description and its code say should
|
|
28
|
-
hold says must and never instead, as name-domain-actions does.
|
|
29
|
-
as a template for repository-specific conventions.
|
|
27
|
+
A guideline, so its description and its code say should and should not. A
|
|
28
|
+
rule that must hold says must and never instead, as name-domain-actions does.
|
|
29
|
+
Use this rule as a template for repository-specific conventions.
|
|
30
30
|
|
|
31
31
|
\`\`\`ts should
|
|
32
32
|
export const parsePort = (value: string) =>
|
|
33
33
|
Port.make(Number.parseInt(value, 10));
|
|
34
34
|
\`\`\`
|
|
35
|
+
|
|
36
|
+
\`\`\`ts should not
|
|
37
|
+
export const parsePort = (value: string) => Port.make(Number.parseInt(value, 10));
|
|
38
|
+
export const sendWelcomeEmail = (to: Email) => Mailer.send(to, welcomeTemplate);
|
|
39
|
+
export const renderInvoice = (invoice: Invoice) => Html.table(invoice.lines);
|
|
40
|
+
\`\`\`
|
|
35
41
|
`;
|
|
36
42
|
|
|
37
43
|
const NAME_EFFECTS = `---
|
package/src/main.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as adhere from "#index.ts";
|
|
|
4
4
|
import { version } from "#version.ts";
|
|
5
5
|
import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
6
6
|
import { plugin } from "bun";
|
|
7
|
-
import { Effect } from "effect";
|
|
7
|
+
import { Cause, Effect, Layer, Logger, References, Runtime } from "effect";
|
|
8
8
|
import { Command } from "effect/unstable/cli";
|
|
9
9
|
|
|
10
10
|
// A config file may import `@drkmttr/adhere`, for `defineConfig`. The compiled
|
|
@@ -17,8 +17,39 @@ plugin({
|
|
|
17
17
|
},
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Every log on stderr, so stdout carries the report and nothing else. The
|
|
22
|
+
* pretty logger picks its stream from `LogToStderr`, not from an option.
|
|
23
|
+
*/
|
|
24
|
+
const logger = Layer.merge(
|
|
25
|
+
Logger.layer([Logger.consolePretty()]),
|
|
26
|
+
Layer.succeed(References.LogToStderr, true),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How a failed run says so, through the logger above: runMain's own report
|
|
31
|
+
* goes to stdout. A failure adhere declares, such as a refused request or a
|
|
32
|
+
* config that does not decode, is its message alone; its stack would only
|
|
33
|
+
* point into adhere. A defect, which is a bug, keeps its whole cause.
|
|
34
|
+
*/
|
|
35
|
+
const report = (cause: Cause.Cause<unknown>): Effect.Effect<void> => {
|
|
36
|
+
if (Cause.hasInterruptsOnly(cause)) return Effect.void;
|
|
37
|
+
const error = Cause.squash(cause);
|
|
38
|
+
const marked = typeof error === "object" && error !== null && Runtime.errorReported in error;
|
|
39
|
+
if (
|
|
40
|
+
marked &&
|
|
41
|
+
(error as { readonly [Runtime.errorReported]: boolean })[Runtime.errorReported] === false
|
|
42
|
+
) {
|
|
43
|
+
return Effect.void;
|
|
44
|
+
}
|
|
45
|
+
return !Cause.hasDies(cause) && error instanceof Error
|
|
46
|
+
? Effect.logError(error.message)
|
|
47
|
+
: Effect.logError(cause);
|
|
48
|
+
};
|
|
49
|
+
|
|
20
50
|
Command.run(cli, { version }).pipe(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
Effect.provide(Layer.merge(BunServices.layer, logger)),
|
|
54
|
+
(program) => BunRuntime.runMain(program, { disableErrorReporting: true }),
|
|
24
55
|
);
|