@drkmttr/adhere 0.10.0 → 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 (51) hide show
  1. package/README.md +177 -41
  2. package/package.json +7 -7
  3. package/presets/effect/basics/gen-for-sequencing.md +1 -1
  4. package/presets/effect/data/brand-meaningful-primitives.md +1 -1
  5. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  6. package/presets/effect/services/dependencies-through-layers.md +1 -1
  7. package/presets/react/effects/clean-up-what-they-start.md +23 -0
  8. package/presets/react/effects/event-logic-in-handlers.md +29 -0
  9. package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
  10. package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
  11. package/presets/react/effects/notify-parents-in-handlers.md +29 -0
  12. package/presets/react/server/no-private-data-to-client.md +23 -0
  13. package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
  14. package/presets/react/state/no-contradictory-state.md +17 -0
  15. package/presets/react/state/no-props-copied-into-state.md +23 -0
  16. package/presets/react/state/store-ids-not-copies.md +20 -0
  17. package/presets/security/constant-time-secret-compare.md +22 -0
  18. package/presets/security/no-secrets-in-output.md +19 -0
  19. package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
  20. package/presets/security/parameterized-queries.md +16 -0
  21. package/presets/typescript/async/concurrent-independent-work.md +23 -0
  22. package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
  23. package/presets/typescript/design/no-argument-mutation.md +20 -0
  24. package/presets/typescript/errors/no-swallowed-errors.md +29 -0
  25. package/presets/typescript/errors/release-on-every-path.md +29 -0
  26. package/presets/typescript/testing/assert-outcomes.md +22 -0
  27. package/presets/typescript/testing/independent-tests.md +41 -0
  28. package/presets/typescript/types/assertions-only-when-proven.md +20 -0
  29. package/presets/typescript/types/derive-dont-restate.md +24 -0
  30. package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
  31. package/presets/typescript/types/validate-external-data.md +21 -0
  32. package/skills/adhere/SKILL.md +4 -3
  33. package/skills/adhere-fix/SKILL.md +10 -4
  34. package/src/cli.ts +9 -1
  35. package/src/config.ts +29 -1
  36. package/src/excerpt.ts +72 -62
  37. package/src/highlight.ts +2 -0
  38. package/src/markdown.ts +6 -0
  39. package/src/presets.ts +6 -0
  40. package/src/rules.ts +1 -1
  41. package/src/services/AuditCache.ts +31 -5
  42. package/src/services/Jev.http.ts +76 -11
  43. package/src/services/Jev.ts +191 -73
  44. package/src/services/SourceWalker.ts +23 -7
  45. package/src/suppress.ts +13 -7
  46. package/src/version.ts +25 -1
  47. package/src/workflows/audit.ts +96 -26
  48. package/src/workflows/format.ts +51 -11
  49. package/presets/alchemy/data/parameterized-sql.md +0 -17
  50. package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -19
  51. package/presets/effect/basics/external-calls-are-resilient.md +0 -21
@@ -1,4 +1,5 @@
1
1
  import { type Example, examplesOf, type Rule, type RuleId, type Rules } from "#config.ts";
2
+ import { type Range, sectionsOf } from "#excerpt.ts";
2
3
  import { Context, type Effect, Record, Schema } from "effect";
3
4
 
4
5
  export type { Rules };
@@ -24,12 +25,20 @@ export class JevBlocked extends Schema.TaggedError<JevBlocked>()("JevBlocked", {
24
25
  ray: Schema.String,
25
26
  }) {}
26
27
 
28
+ /** Jev's probability for each of a rule's matchers, in the order the rule lists them. */
29
+ export interface MatcherScores {
30
+ readonly appliesTo: ReadonlyArray<number>;
31
+ readonly excludeIf: ReadonlyArray<number>;
32
+ }
33
+
27
34
  /** What judging a file answers: per rule, whether it breaks it, and the linter check's answers. */
28
35
  export interface Judged {
29
36
  /** Per rule, Jev's probability that the file breaks it. */
30
37
  readonly probabilities: Readonly<Record<RuleId, number>>;
31
38
  /** Per sampled rule, Jev's probability that a regular linter could have decided the file against it. */
32
39
  readonly linter: Readonly<Record<RuleId, number>>;
40
+ /** Per rule with `appliesTo` or `excludeIf`, the scores of its matchers. */
41
+ readonly matchers?: Readonly<Record<RuleId, MatcherScores>>;
33
42
  }
34
43
 
35
44
  export class Jev extends Context.Service<
@@ -45,10 +54,15 @@ export class Jev extends Context.Service<
45
54
  rules: Rules,
46
55
  sampled?: ReadonlyArray<RuleId>,
47
56
  ) => Effect.Effect<Judged, JevUnavailable | JevOverflow | JevBlocked>;
57
+ /**
58
+ * Per rule, the section of the file that most clearly breaks it, and
59
+ * whether the file shows enough to decide. A rule Jev answers either
60
+ * question for with nothing it offered is left out.
61
+ */
48
62
  readonly locate: (
49
63
  lines: Lines,
50
64
  rules: Rules,
51
- ) => Effect.Effect<Record<RuleId, number>, JevUnavailable | JevOverflow | JevBlocked>;
65
+ ) => Effect.Effect<Record<RuleId, Located>, JevUnavailable | JevOverflow | JevBlocked>;
52
66
  /**
53
67
  * Per rule, the partner Jev names as impossible to follow in the same code,
54
68
  * as `[rule, partner]` indexes into `rules`. A rule Jev names none for is
@@ -69,6 +83,15 @@ export class Jev extends Context.Service<
69
83
  }
70
84
  >()("@drkmttr/adhere/services/Jev") {}
71
85
 
86
+ /** Where a finding is, and how sure Jev is that the file shows enough to decide it. */
87
+ export interface Located {
88
+ readonly section: Range;
89
+ /** Jev's probability that everything judging the rule turns on is in the file. */
90
+ readonly sufficiency: number;
91
+ /** The lines in the section the code that breaks the rule starts and ends on, when Jev named them. */
92
+ readonly lines?: Range;
93
+ }
94
+
72
95
  /** A rule compared with other rules rather than with a file. */
73
96
  export interface ComparedRule {
74
97
  readonly id: RuleId;
@@ -78,10 +101,8 @@ export interface ComparedRule {
78
101
  /** Two rules, as indexes into the rules being compared. */
79
102
  export type Pair = readonly [number, number];
80
103
 
81
- /** A `choice` question accepts at most this many criteria. */
104
+ /** A `choice` question accepts at most this many criteria, so a file has at most this many sections. */
82
105
  const CHOICE_LIMIT = 255;
83
- const LINES_PER_BLOCK = 20;
84
- const MAX_LINES = CHOICE_LIMIT * LINES_PER_BLOCK;
85
106
 
86
107
  /**
87
108
  * Jev 1.13's context, from its model card: the state with its longest
@@ -103,16 +124,19 @@ const encoder = new TextEncoder();
103
124
  export const tokensOf = (value: unknown): number =>
104
125
  Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
105
126
 
106
- const snippetOf = (line: string): string => line.trim().slice(0, 120);
107
-
108
- const isBlank = (line: string): boolean => line.trim().length === 0;
109
-
110
- export const needsBlocks = (lines: Lines): boolean =>
111
- lines.filter((line) => !isBlank(line)).length > CHOICE_LIMIT;
112
-
113
- /** The file, numbered so a line question can name a line. The only thing the questions share. */
127
+ /**
128
+ * The file as Jev reads it: its sections, by `sectionsOf`, under their
129
+ * numbers from 1, so the locate question can name one. The only thing the
130
+ * questions share. On the eval it judged as well as the file with every line
131
+ * numbered, and located as well as a choice among the lines.
132
+ */
114
133
  const stateOf = (lines: Lines) => ({
115
- code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
134
+ code: Object.fromEntries(
135
+ sectionsOf(lines).map((section, index) => [
136
+ String(index + 1),
137
+ lines.slice(section.first - 1, section.last).join("\n"),
138
+ ]),
139
+ ),
116
140
  });
117
141
 
118
142
  /** The field an example goes under in a question: its word, with `should not` as `should_not`. */
@@ -187,7 +211,7 @@ export const judgeQuestion = (rule: Rule) => {
187
211
  };
188
212
  };
189
213
 
190
- /** What the offending line does, for the line and block questions. */
214
+ /** What the offending section does, for the locate question. */
191
215
  const offense = (rule: Rule): string => {
192
216
  const { good, bad } = examplesOf(rule);
193
217
  const avoided = `\`${fieldOf(bad ?? { word: "never", code: "" })}\``;
@@ -198,15 +222,6 @@ const offense = (rule: Rule): string => {
198
222
  : `diverges from \`${fieldOf(good)}\` or resembles ${avoided}`;
199
223
  };
200
224
 
201
- const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
202
- const criteria: Record<string, string> = {};
203
- for (let n = from; n <= Math.min(to, lines.length); n++) {
204
- const text = lines[n - 1] ?? "";
205
- if (!isBlank(text)) criteria[String(n)] = snippetOf(text);
206
- }
207
- return criteria;
208
- };
209
-
210
225
  /**
211
226
  * The linter check's question: whether a regular linter could have decided
212
227
  * `code` against the rule. It carries the judge question's fields, so Jev
@@ -232,11 +247,56 @@ export const linterQuestion = (rule: Rule) => {
232
247
  /** The key a rule's linter question rides under, beside its judge question. */
233
248
  export const linterKey = (id: RuleId): string => `linter:${id}`;
234
249
 
250
+ type MatcherKind = keyof MatcherScores;
251
+
252
+ /**
253
+ * A matcher's question: whether the code that breaks the rule is as the
254
+ * matcher describes. It asks about that code rather than the file, so a file
255
+ * with a real violation beside code a matcher excludes keeps its finding: for
256
+ * `appliesTo`, whether any of that code is as described, and for `excludeIf`,
257
+ * whether all of it is. It carries the rule as the judge question does.
258
+ */
259
+ export const matcherQuestion = (rule: Rule, kind: MatcherKind, matcher: string) => {
260
+ const { instructions } = judgeQuestion(rule);
261
+ const [quantity, yes, no] =
262
+ kind === "appliesTo" ? ["any", "Some", "None"] : ["all", "All", "Not all"];
263
+ return {
264
+ type: "noul" as const,
265
+ instructions: {
266
+ ...instructions,
267
+ question: `Is ${quantity} of the code in \`code\` that breaks \`rule\` as \`matcher\` describes? Answer no if no code in \`code\` breaks \`rule\`.`,
268
+ matcher,
269
+ },
270
+ criteria: {
271
+ true: `${yes} of the code in \`code\` that breaks \`rule\` is as \`matcher\` describes`,
272
+ false: `${no} of the code in \`code\` that breaks \`rule\` is as \`matcher\` describes, or no code in \`code\` breaks \`rule\``,
273
+ },
274
+ };
275
+ };
276
+
277
+ /** The key a rule's matcher question rides under, beside its judge question. */
278
+ export const matcherKey = (kind: MatcherKind, id: RuleId, index: number): string =>
279
+ `${kind}:${id}:${index}`;
280
+
281
+ const MATCHER_KINDS: ReadonlyArray<MatcherKind> = ["appliesTo", "excludeIf"];
282
+
283
+ /** A rule's matcher questions under their keys, `appliesTo` first, each in the rule's order. */
284
+ export const matcherQuestions = (id: RuleId, rule: Rule) =>
285
+ MATCHER_KINDS.flatMap((kind) =>
286
+ (rule[kind] ?? []).map(
287
+ (matcher, index) =>
288
+ [matcherKey(kind, id, index), matcherQuestion(rule, kind, matcher)] as const,
289
+ ),
290
+ );
291
+
235
292
  /** The tokens a file's code leaves in Jev's context for any one question beside it, as `fits` counts them. */
236
293
  export const questionRoom = (lines: Lines): number =>
237
294
  QUESTION_CONTEXT - WRAPPER - tokensOf(stateOf(lines));
238
295
 
239
- /** One question per rule, and the linter question beside each rule in `sampled`. */
296
+ /**
297
+ * One question per rule, one per matcher of each rule, and the linter
298
+ * question beside each rule in `sampled`.
299
+ */
240
300
  export const judgeBody = (
241
301
  model: string,
242
302
  lines: Lines,
@@ -247,6 +307,9 @@ export const judgeBody = (
247
307
  state: stateOf(lines),
248
308
  questions: {
249
309
  ...Record.map(rules, (rule): ReturnType<typeof judgeQuestion> => judgeQuestion(rule)),
310
+ ...Object.fromEntries(
311
+ Object.entries(rules).flatMap(([id, rule]) => matcherQuestions(id, rule)),
312
+ ),
250
313
  ...Object.fromEntries(
251
314
  sampled.flatMap((id) => {
252
315
  const rule = rules[id];
@@ -256,62 +319,123 @@ export const judgeBody = (
256
319
  },
257
320
  });
258
321
 
259
- export const locateBody = (
260
- model: string,
322
+ /**
323
+ * Whether the file shows enough to decide the rule at all: a finding that
324
+ * turns on another file, a library, or a service is a coin flip, and the
325
+ * report says so. It asks the condition itself, beside the rule: on the
326
+ * eval, asking whether `code` held enough to answer the judge question, put
327
+ * in a field, ranked real findings worse, and read no easier. Of findings
328
+ * it scored below 0.6, 62% were false, where 22% of all were.
329
+ */
330
+ export const sufficiencyQuestion = (rule: Rule) => ({
331
+ type: "noul" as const,
332
+ instructions: {
333
+ question:
334
+ "Can you tell whether `code` breaks `rule` from `code` alone, without knowing what other files, libraries, services, or configuration do?",
335
+ ...ruleFields(rule),
336
+ },
337
+ criteria: {
338
+ true: "`code` shows everything needed to tell whether it breaks `rule`",
339
+ false:
340
+ "Whether `code` breaks `rule` depends on something `code` does not show, such as another file, a library, a service, or configuration",
341
+ },
342
+ });
343
+
344
+ /** The key a rule's sufficiency question rides under, beside its locate question. */
345
+ export const sufficiencyKey = (id: RuleId): string => `sufficient:${id}`;
346
+
347
+ /** The key of the question where in section `section` (from 1) the code that breaks a rule starts or ends. */
348
+ export const edgeKey = (edge: "start" | "end", id: RuleId, section: number): string =>
349
+ `${edge}:${id}:${section}`;
350
+
351
+ /**
352
+ * Which line of one section the code that breaks the rule starts or ends
353
+ * on: a choice among the section's lines that hold code, each option the
354
+ * line's text, since the state carries no line numbers.
355
+ */
356
+ const edgeQuestion = (
357
+ rule: Rule,
358
+ edge: "start" | "end",
359
+ key: string,
261
360
  lines: Lines,
262
- rules: Rules,
263
- blocks?: Readonly<Record<RuleId, number>>,
361
+ numbers: ReadonlyArray<number>,
264
362
  ) => ({
265
- model,
266
- state: stateOf(lines),
267
- questions: Record.map(rules, (rule, id) => {
268
- const block = blocks?.[id];
269
- return {
270
- type: "choice" as const,
271
- instructions: {
272
- question: `Which line of \`code\` most clearly ${offense(rule)}?`,
273
- ...ruleFields(rule),
274
- },
275
- criteria:
276
- block === undefined
277
- ? lineCriteria(lines, 1, lines.length)
278
- : lineCriteria(lines, block * LINES_PER_BLOCK + 1, (block + 1) * LINES_PER_BLOCK),
279
- };
280
- }),
363
+ type: "choice" as const,
364
+ instructions: {
365
+ question: `Which line of \`code["${key}"]\` does the code that breaks \`rule\` ${edge} on?`,
366
+ ...ruleFields(rule),
367
+ },
368
+ criteria: Object.fromEntries(
369
+ numbers.map((line) => [String(line), (lines[line - 1] ?? "").trim().slice(0, 120)]),
370
+ ),
281
371
  });
282
372
 
283
- export const blockBody = (model: string, lines: Lines, rules: Rules) => {
284
- const criteria: Record<string, string> = {};
285
- for (let block = 0; block * LINES_PER_BLOCK < lines.length; block++) {
286
- const first = lines
287
- .slice(block * LINES_PER_BLOCK, (block + 1) * LINES_PER_BLOCK)
288
- .find((line) => !isBlank(line));
289
- if (first !== undefined) criteria[String(block)] = `${snippetOf(first)}...`;
290
- }
373
+ /**
374
+ * Per rule, which section of `code` breaks it, one choice among the
375
+ * sections' numbers; for every section, the lines in it the code that breaks
376
+ * the rule starts and ends on, of which the chosen section's are kept; and
377
+ * the sufficiency question. The section options carry no text: each is a
378
+ * key of the state, and describing a section by its first line or by what
379
+ * it declares located no better on the eval. A file of one section has no
380
+ * section to choose. On the eval, asked together, the start and end lines
381
+ * held a violation for 94% of real findings, and 98% where the section was
382
+ * right, and were most often one line.
383
+ */
384
+ export const locateBody = (model: string, lines: Lines, rules: Rules) => {
385
+ const state = stateOf(lines);
386
+ const criteria = Record.map(state.code, () => null);
387
+ const choosing = Object.keys(criteria).length > 1;
388
+ const withCode = sectionsOf(lines).map(({ first, last }) =>
389
+ Array.from({ length: last - first + 1 }, (_, index) => first + index).filter(
390
+ (line) => (lines[line - 1] ?? "").trim() !== "",
391
+ ),
392
+ );
291
393
  return {
292
394
  model,
293
- state: stateOf(lines),
294
- questions: Record.map(rules, (rule) => ({
295
- type: "choice" as const,
296
- instructions: {
297
- question: `Which block of \`code\` contains the line that most clearly ${offense(rule)}?`,
298
- ...ruleFields(rule),
299
- },
300
- criteria,
301
- })),
395
+ state,
396
+ questions: Object.fromEntries(
397
+ Object.entries(rules).flatMap(([id, rule]) => [
398
+ ...(choosing
399
+ ? [
400
+ [
401
+ id,
402
+ {
403
+ type: "choice" as const,
404
+ instructions: {
405
+ question: `Which entry of \`code\` most clearly ${offense(rule)}?`,
406
+ ...ruleFields(rule),
407
+ },
408
+ criteria,
409
+ },
410
+ ] as const,
411
+ ]
412
+ : []),
413
+ ...withCode.flatMap((numbers, index) =>
414
+ numbers.length === 0
415
+ ? []
416
+ : (["start", "end"] as const).map(
417
+ (edge) =>
418
+ [
419
+ edgeKey(edge, id, index + 1),
420
+ edgeQuestion(rule, edge, String(index + 1), lines, numbers),
421
+ ] as const,
422
+ ),
423
+ ),
424
+ [sufficiencyKey(id), sufficiencyQuestion(rule)] as const,
425
+ ]),
426
+ ),
302
427
  };
303
428
  };
304
429
 
305
430
  /**
306
- * Whether Jev can take every request a file may need: its lines within two
307
- * line choices, and its code beside the longest question any rule asks.
431
+ * Whether Jev can take every request a file may need: its sections within
432
+ * one choice, and its code beside the longest question any rule asks.
308
433
  */
309
434
  export const fits = (lines: Lines, rules: Rules): boolean => {
310
- if (lines.length > MAX_LINES) return false;
311
- const located = needsBlocks(lines) ? blockBody("", lines, rules) : locateBody("", lines, rules);
435
+ if (sectionsOf(lines).length > CHOICE_LIMIT) return false;
312
436
  const questions = [
313
437
  ...Object.values(judgeBody("", lines, rules).questions),
314
- ...Object.values(located.questions),
438
+ ...Object.values(locateBody("", lines, rules).questions),
315
439
  ];
316
440
  const longest = Math.max(0, ...questions.map(tokensOf));
317
441
  return tokensOf(stateOf(lines)) + longest <= QUESTION_CONTEXT - WRAPPER;
@@ -368,14 +492,8 @@ export const judgeRequests = (
368
492
  sampled: ReadonlyArray<RuleId> = [],
369
493
  ): number => requestsOf(judgeBody("", lines, rules, sampled)).length;
370
494
 
371
- /**
372
- * The requests locating `rules` in a file takes, split to fit as `locate`
373
- * sends them: a block choice first for a long file, then a line choice. For a
374
- * long file the line choice is counted before a block is chosen, so it can
375
- * run high.
376
- */
495
+ /** The requests locating `rules` in a file takes, split to fit as `locate` sends them. */
377
496
  export const locateRequests = (lines: Lines, rules: Rules): number =>
378
- (needsBlocks(lines) ? requestsOf(blockBody("", lines, rules)).length : 0) +
379
497
  requestsOf(locateBody("", lines, rules)).length;
380
498
 
381
499
  /** A choice keeps one criterion for "none", which leaves this many for partners. */
@@ -52,15 +52,31 @@ export const passesFilter = (relative: string, patterns: ReadonlyArray<string>):
52
52
  };
53
53
 
54
54
  /**
55
- * Whether a path, relative to the working directory, is a test: a `.test.ts`
56
- * or `.spec.ts` file, or any file under a test directory such as `test/`.
55
+ * A fixture or test helper named as one: `fixtures.ts`, `GitHubHttpFixtures.ts`,
56
+ * `ledger-copy.fixture.ts`, `linear-test-helpers.ts`, `GitHubHttpTestUtils.ts`.
57
+ * "test" starts a word, so `latest-utils.ts` is not one.
58
+ */
59
+ const TEST_SUPPORT_NAME =
60
+ /(?:[fF]ixtures?|(?:(?:^|[-_.])[tT]est[-_]?|[a-z]Test)(?:[hH]elpers?|[uU]tils?))\.[cm]?tsx?$/;
61
+
62
+ /** A TypeScript source file: `.ts`, `.tsx`, `.mts`, or `.cts`. */
63
+ const SOURCE = /\.(?:tsx?|mts|cts)$/;
64
+ /** A declaration file: `.d.ts`, `.d.mts`, or `.d.cts`. */
65
+ const DECLARATION = /\.d\.[cm]?ts$/;
66
+ /** A test by its name: `a.test.ts`, `Button.spec.tsx`. */
67
+ const TEST_NAME = /\.(?:test|spec)\.[cm]?tsx?$/;
68
+
69
+ /**
70
+ * Whether a path, relative to the working directory, is a test: a `.test` or
71
+ * `.spec` file, such as `a.test.ts` or `Button.spec.tsx`, a fixture or test
72
+ * helper named as one, or any file under a test directory such as `test/`.
57
73
  */
58
74
  export const isTestFile = (relative: string): boolean => {
59
75
  const segments = relative.split(/[\\/]/);
60
76
  const name = segments.at(-1) ?? "";
61
77
  return (
62
- name.endsWith(".test.ts") ||
63
- name.endsWith(".spec.ts") ||
78
+ TEST_NAME.test(name) ||
79
+ TEST_SUPPORT_NAME.test(name) ||
64
80
  segments.slice(0, -1).some((segment) => TEST_DIRECTORIES.has(segment))
65
81
  );
66
82
  };
@@ -68,9 +84,9 @@ export const isTestFile = (relative: string): boolean => {
68
84
  const isInside = (file: string, root: string): boolean =>
69
85
  file === root || file.startsWith(`${root}/`);
70
86
 
71
- const isScannable = (file: string, self: string): boolean =>
72
- file.endsWith(".ts") &&
73
- !file.endsWith(".d.ts") &&
87
+ export const isScannable = (file: string, self: string): boolean =>
88
+ SOURCE.test(file) &&
89
+ !DECLARATION.test(file) &&
74
90
  !file.endsWith("/adhere.config.ts") &&
75
91
  !file.endsWith("/.adhere.config.ts") &&
76
92
  // The audit's own detector patterns are data, not violations of themselves.
package/src/suppress.ts CHANGED
@@ -5,10 +5,11 @@ import { scan } from "#highlight.ts";
5
5
  * What a file's `adhere-ignore` comments suppress. `// adhere-ignore <rules>
6
6
  * -- <reason>` on a line of its own suppresses the rules it names in the
7
7
  * statement that starts on the next line of code, wherever in it Jev points;
8
- * at the end of a line of code, in the statement that starts on that line, or
9
- * the line alone. `// adhere-ignore-file <rules> -- <reason>` suppresses them
10
- * in the whole file, which they then do not judge. Rules are named as a report
11
- * names them, separated by commas.
8
+ * at the end of a line of code, in the statement that starts on that line,
9
+ * or the line alone.
10
+ * `// adhere-ignore-file <rules> -- <reason>` suppresses them in the whole
11
+ * file, which they then do not judge. Rules are named as a report names
12
+ * them, separated by commas.
12
13
  */
13
14
  export interface Suppressions {
14
15
  /** Rules suppressed in the whole file. */
@@ -105,9 +106,14 @@ export const suppressionsOf = (
105
106
  };
106
107
  };
107
108
 
108
- /** Whether a file's comments suppress `rule`, named as a report names it, at `line`. */
109
- export const suppresses = (suppressions: Suppressions, rule: string, line: number): boolean =>
109
+ /**
110
+ * Whether a file's comments suppress `rule`, named as a report names it, at
111
+ * `place`, the lines Jev points at or, without them, their section: in the
112
+ * whole file, or in a statement that overlaps it.
113
+ */
114
+ export const suppresses = (suppressions: Suppressions, rule: string, place: Range): boolean =>
110
115
  suppressions.file.has(rule) ||
111
116
  suppressions.statements.some(
112
- (statement) => statement.rules.has(rule) && statement.first <= line && line <= statement.last,
117
+ (statement) =>
118
+ statement.rules.has(rule) && statement.first <= place.last && place.first <= statement.last,
113
119
  );
package/src/version.ts CHANGED
@@ -1,3 +1,27 @@
1
1
  import packageJson from "../package.json";
2
2
 
3
- export const version = packageJson.version;
3
+ /**
4
+ * `git describe --tags --dirty --always` where `bun run build` compiled
5
+ * this, which it defines: the release it follows, the commits since, and
6
+ * whether the tree had changes, or the commit alone in a clone without tags.
7
+ * A release build defines nothing.
8
+ */
9
+ declare const ADHERE_BUILD: string;
10
+
11
+ /** Read off `globalThis`, as in `presets.ts`, for Vitest on Node and for Bun's types left out. */
12
+ const isStandaloneExecutable =
13
+ (globalThis as { readonly Bun?: { readonly isStandaloneExecutable?: boolean } }).Bun
14
+ ?.isStandaloneExecutable === true;
15
+
16
+ /**
17
+ * What `--version` prints after `adhere v`: a local build's `git describe`,
18
+ * such as `0.10.0-16-g9a41328`, so it is not taken for the release it
19
+ * follows; a release build's version; and from source, the version the
20
+ * checkout says, marked as such.
21
+ */
22
+ export const version =
23
+ typeof ADHERE_BUILD === "string" && ADHERE_BUILD !== ""
24
+ ? ADHERE_BUILD.replace(/^v/, "")
25
+ : isStandaloneExecutable
26
+ ? packageJson.version
27
+ : `${packageJson.version} (source)`;