@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.
Files changed (113) hide show
  1. package/README.md +406 -90
  2. package/package.json +7 -7
  3. package/presets/alchemy/apis/match-remote-errors-by-tag.md +6 -2
  4. package/presets/alchemy/apis/schemas-at-trust-boundaries.md +6 -2
  5. package/presets/alchemy/data/commit-migrations-then-deploy.md +6 -2
  6. package/presets/alchemy/data/hyperdrive-direct-origin.md +6 -2
  7. package/presets/alchemy/data/migrations-use-direct-url.md +6 -2
  8. package/presets/alchemy/data/never-disable-tls-verification.md +6 -2
  9. package/presets/alchemy/data/private-endpoint-at-runtime.md +6 -2
  10. package/presets/alchemy/data/single-migration-owner.md +6 -2
  11. package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
  12. package/presets/alchemy/durable/io-inside-steps.md +6 -2
  13. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
  14. package/presets/alchemy/durable/stable-step-names.md +6 -2
  15. package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
  16. package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
  17. package/presets/alchemy/providers/idempotent-delete.md +8 -3
  18. package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
  19. package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
  20. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
  21. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
  22. package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
  23. package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
  24. package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
  25. package/presets/alchemy/resources/stable-logical-ids.md +6 -2
  26. package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
  27. package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
  28. package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
  29. package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
  30. package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
  31. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
  32. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
  33. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
  34. package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
  35. package/presets/alchemy/secrets/redacted-until-use.md +6 -2
  36. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
  37. package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
  38. package/presets/alchemy/security/github-secret-not-variable.md +6 -2
  39. package/presets/alchemy/security/identity-from-verified-session.md +6 -2
  40. package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
  41. package/presets/alchemy/security/least-privilege-bindings.md +6 -2
  42. package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
  43. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
  44. package/presets/effect/basics/gen-for-sequencing.md +7 -3
  45. package/presets/effect/basics/instrument-with-pipe.md +6 -2
  46. package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
  47. package/presets/effect/config/secrets-are-redacted.md +6 -2
  48. package/presets/effect/config/tests-provide-values-directly.md +7 -2
  49. package/presets/effect/config/validate-with-schema.md +6 -2
  50. package/presets/effect/data/brand-meaningful-primitives.md +7 -3
  51. package/presets/effect/data/variants-are-tagged-unions.md +7 -3
  52. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  53. package/presets/effect/errors/defects-for-bugs.md +6 -2
  54. package/presets/effect/errors/wrap-external-errors.md +6 -2
  55. package/presets/effect/services/dependencies-through-layers.md +7 -3
  56. package/presets/effect/services/fresh-layer-per-test.md +7 -2
  57. package/presets/effect/services/memoize-parameterized-layers.md +6 -2
  58. package/presets/effect/services/no-mutable-state.md +6 -2
  59. package/presets/effect/services/operations-have-no-requirements.md +6 -2
  60. package/presets/effect/services/test-layers-are-in-memory.md +7 -2
  61. package/presets/effect/testing/test-clock-for-time.md +7 -2
  62. package/presets/react/effects/clean-up-what-they-start.md +23 -0
  63. package/presets/react/effects/event-logic-in-handlers.md +29 -0
  64. package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
  65. package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
  66. package/presets/react/effects/notify-parents-in-handlers.md +29 -0
  67. package/presets/react/server/no-private-data-to-client.md +23 -0
  68. package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
  69. package/presets/react/state/no-contradictory-state.md +17 -0
  70. package/presets/react/state/no-props-copied-into-state.md +23 -0
  71. package/presets/react/state/store-ids-not-copies.md +20 -0
  72. package/presets/security/constant-time-secret-compare.md +22 -0
  73. package/presets/security/no-secrets-in-output.md +19 -0
  74. package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
  75. package/presets/security/parameterized-queries.md +16 -0
  76. package/presets/typescript/async/concurrent-independent-work.md +23 -0
  77. package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
  78. package/presets/typescript/design/no-argument-mutation.md +20 -0
  79. package/presets/typescript/errors/no-swallowed-errors.md +29 -0
  80. package/presets/typescript/errors/release-on-every-path.md +29 -0
  81. package/presets/typescript/testing/assert-outcomes.md +22 -0
  82. package/presets/typescript/testing/independent-tests.md +41 -0
  83. package/presets/typescript/types/assertions-only-when-proven.md +20 -0
  84. package/presets/typescript/types/derive-dont-restate.md +24 -0
  85. package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
  86. package/presets/typescript/types/validate-external-data.md +21 -0
  87. package/skills/adhere/SKILL.md +41 -12
  88. package/skills/adhere-fix/SKILL.md +180 -0
  89. package/src/cli.ts +59 -14
  90. package/src/comments.ts +36 -0
  91. package/src/config.ts +126 -14
  92. package/src/contradictions.ts +10 -2
  93. package/src/excerpt.ts +78 -58
  94. package/src/highlight.ts +2 -0
  95. package/src/index.ts +1 -1
  96. package/src/init.ts +119 -12
  97. package/src/main.ts +0 -1
  98. package/src/markdown.ts +83 -14
  99. package/src/models/Audit.ts +2 -0
  100. package/src/presets.ts +12 -3
  101. package/src/rules.ts +36 -3
  102. package/src/services/AdhereConfig.ts +36 -13
  103. package/src/services/AuditCache.ts +31 -5
  104. package/src/services/Jev.http.ts +76 -11
  105. package/src/services/Jev.ts +191 -73
  106. package/src/services/SourceWalker.ts +51 -13
  107. package/src/suppress.ts +119 -0
  108. package/src/version.ts +25 -1
  109. package/src/workflows/audit.ts +192 -40
  110. package/src/workflows/format.ts +71 -24
  111. package/presets/alchemy/data/parameterized-sql.md +0 -13
  112. package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -15
  113. 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
- export interface AdhereConfig extends Schema.Schema.Type<typeof AdhereConfig> {}
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 Overrides {
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
- overrides: Overrides,
271
+ config: Pick<typeof AdhereConfig.Type, "presets">,
272
+ flags: Flags,
172
273
  ): ReadonlyArray<PresetName> => {
173
- const named = [...new Set([...(overrides.presets ?? []), ...config.presets])];
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
- overrides: Overrides = {},
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, overrides).flatMap((name) => {
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: overrides.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_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
- ...(overrides.rpm === undefined ? {} : { rpm: overrides.rpm }),
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
- export const decodeConfig = (value: unknown): Effect.Effect<AdhereConfig, ConfigUnavailable> =>
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({
@@ -1,4 +1,5 @@
1
- import { type RuleEntry, shownId } from "#rules.ts";
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 && first.id !== second.id && scopesOverlap(first.scope, second.scope)
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, spans };
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 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.
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 excerptOf = (lines: ReadonlyArray<string>, line: number): Excerpt => {
226
- const { pieces, spans } = piecesOf(lines.join("\n"));
211
+ export const sectionsOf = (lines: ReadonlyArray<string>): ReadonlyArray<Range> => {
212
+ const { pieces } = piecesOf(lines.join("\n"));
227
213
  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) };
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 { access, mkdir, writeFile } from "node:fs/promises";
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
- const CONFIG = `import type { Config } from "@drkmttr/adhere";
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
- } satisfies Config;
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 code say should and should not. A
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
- \`\`\`ts should
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
- \`\`\`ts should not
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
- \`\`\`ts must
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
- A fence tagged \`never\` shows what a violation looks like. It is optional, and
56
- a rule can have one without the block above.
90
+ ## Never
57
91
 
58
- \`\`\`ts never
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
- return { created, skipped };
231
+ const install = yield* installAdhere(root);
232
+ return { created, skipped, install };
126
233
  });