@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.
Files changed (80) hide show
  1. package/README.md +177 -37
  2. package/package.json +12 -12
  3. package/presets/alchemy/apis/match-remote-errors-by-tag.md +15 -0
  4. package/presets/alchemy/apis/schemas-at-trust-boundaries.md +18 -0
  5. package/presets/alchemy/data/commit-migrations-then-deploy.md +15 -0
  6. package/presets/alchemy/data/hyperdrive-direct-origin.md +14 -0
  7. package/presets/alchemy/data/migrations-use-direct-url.md +11 -0
  8. package/presets/alchemy/data/never-disable-tls-verification.md +16 -0
  9. package/presets/alchemy/data/parameterized-sql.md +13 -0
  10. package/presets/alchemy/data/private-endpoint-at-runtime.md +12 -0
  11. package/presets/alchemy/data/single-migration-owner.md +13 -0
  12. package/presets/alchemy/durable/idempotent-event-handlers.md +15 -0
  13. package/presets/alchemy/durable/io-inside-steps.md +17 -0
  14. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +15 -0
  15. package/presets/alchemy/durable/stable-step-names.md +11 -0
  16. package/presets/alchemy/durable/webhook-state-is-truth.md +14 -0
  17. package/presets/alchemy/providers/diff-guards-unresolved-props.md +16 -0
  18. package/presets/alchemy/providers/idempotent-delete.md +14 -0
  19. package/presets/alchemy/providers/lazy-layer-construction.md +14 -0
  20. package/presets/alchemy/providers/reconcile-from-observed-state.md +20 -0
  21. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +14 -0
  22. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +19 -0
  23. package/presets/alchemy/resources/previews-reference-shared-resources.md +15 -0
  24. package/presets/alchemy/resources/protect-irreplaceable-data.md +12 -0
  25. package/presets/alchemy/resources/remote-state-for-ci.md +22 -0
  26. package/presets/alchemy/resources/stable-logical-ids.md +13 -0
  27. package/presets/alchemy/resources/stage-unique-physical-names.md +15 -0
  28. package/presets/alchemy/runtime/bind-tag-not-implementation.md +27 -0
  29. package/presets/alchemy/runtime/deterministic-config-defaults.md +13 -0
  30. package/presets/alchemy/runtime/disposables-in-request-scope.md +27 -0
  31. package/presets/alchemy/runtime/lambda-no-post-response-work.md +19 -0
  32. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +24 -0
  33. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +31 -0
  34. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +13 -0
  35. package/presets/alchemy/secrets/never-logged-returned-or-output.md +15 -0
  36. package/presets/alchemy/secrets/none-in-browser-env.md +15 -0
  37. package/presets/alchemy/secrets/redacted-until-use.md +19 -0
  38. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +17 -0
  39. package/presets/alchemy/security/authorize-public-endpoints.md +20 -0
  40. package/presets/alchemy/security/github-secret-not-variable.md +12 -0
  41. package/presets/alchemy/security/identity-from-verified-session.md +14 -0
  42. package/presets/alchemy/security/keep-auth-protections-on.md +19 -0
  43. package/presets/alchemy/security/least-privilege-bindings.md +13 -0
  44. package/presets/alchemy/security/least-privilege-ci-credentials.md +24 -0
  45. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +15 -0
  46. package/presets/effect/basics/external-calls-are-resilient.md +3 -3
  47. package/presets/effect/config/tests-provide-values-directly.md +3 -3
  48. package/presets/effect/config/validate-with-schema.md +13 -2
  49. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  50. package/presets/effect/services/dependencies-through-layers.md +42 -0
  51. package/presets/effect/services/operations-have-no-requirements.md +46 -0
  52. package/presets/effect/services/test-layers-are-in-memory.md +3 -3
  53. package/presets/effect/testing/test-clock-for-time.md +1 -1
  54. package/skills/adhere/SKILL.md +3 -2
  55. package/src/cli.ts +42 -16
  56. package/src/config.ts +12 -2
  57. package/src/excerpt.ts +255 -0
  58. package/src/highlight.ts +82 -20
  59. package/src/init.ts +10 -4
  60. package/src/main.ts +35 -4
  61. package/src/mechanical.ts +89 -0
  62. package/src/presets.ts +45 -8
  63. package/src/rules.ts +2 -0
  64. package/src/services/AdhereConfig.ts +18 -20
  65. package/src/services/AuditCache.ts +272 -23
  66. package/src/services/Jev.http.ts +66 -12
  67. package/src/services/Jev.ts +66 -6
  68. package/src/services/SourceWalker.ts +11 -4
  69. package/src/wording.ts +90 -0
  70. package/src/workflows/audit.ts +169 -26
  71. package/src/workflows/format.ts +35 -18
  72. package/presets/effect/basics/fn-for-named-effects.md +0 -19
  73. package/presets/effect/cli/handlers-delegate.md +0 -24
  74. package/presets/effect/data/decode-json-with-schema.md +0 -18
  75. package/presets/effect/data/records-are-schema-classes.md +0 -23
  76. package/presets/effect/errors/domain-errors-are-tagged.md +0 -19
  77. package/presets/effect/platform/os-access-through-platform-services.md +0 -26
  78. package/presets/effect/services/methods-have-no-requirements.md +0 -20
  79. package/presets/effect/services/provide-at-entry.md +0 -23
  80. 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. Real sleeps and it.live must be used only when real time is required.
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
@@ -103,14 +103,15 @@ export default {
103
103
  } satisfies Config;
104
104
  ```
105
105
 
106
- Add `.adhere/cache/` to `.gitignore`. Uncached files need a TypeSafe AI API
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, asks Jev about contradictions
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("Add a built-in rule set. With a preset, .adhere/config.ts is optional."),
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
- const live = process.stderr.isTTY === true;
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
- JevLive.pipe(
205
- Layer.provide([FetchHttpClient.layer, CredentialsLive]),
206
- Layer.provideMerge(AdhereConfigLive(overrides)),
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 Jev on whether any two rules
211
- * that apply to the same files contradict. A config, rule file, or preset that
212
- * does not decode refuses here the way it would refuse `lint`.
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 entries = config.scopedRules ?? globalRuleSet(config.rules, path.resolve());
219
- const contradictions = yield* findContradictions(entries, config.threshold);
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* Console.log([loaded, formatContradictions(contradictions)].join("\n"));
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
- { command: "adhere validate", description: "Check the config and rules without calling Jev" },
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
- /** The presets a run applies, command-line ones first. */
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> => [...(overrides.presets ?? []), ...config.presets];
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]|\\.)*'?|`(?:[^`\\]|\\[\s\S])*`?)/,
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
- // The last token that was not whitespace or a comment.
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. A rule that must
28
- hold says must and never instead, as name-domain-actions does. Use this rule
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
- // oxlint-disable-next-line effecttsgo/strict-effect-provide -- this executable entrypoint supplies Bun's process services.
22
- Effect.provide(BunServices.layer),
23
- BunRuntime.runMain,
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
  );