@drkmttr/adhere 0.10.0 → 0.12.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 (53) hide show
  1. package/README.md +226 -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 +85 -4
  35. package/src/config.ts +29 -1
  36. package/src/excerpt.ts +72 -62
  37. package/src/highlight.ts +2 -0
  38. package/src/init.ts +168 -13
  39. package/src/install.ts +147 -0
  40. package/src/markdown.ts +6 -0
  41. package/src/presets.ts +6 -0
  42. package/src/rules.ts +1 -1
  43. package/src/services/AuditCache.ts +31 -5
  44. package/src/services/Jev.http.ts +76 -11
  45. package/src/services/Jev.ts +191 -73
  46. package/src/services/SourceWalker.ts +23 -7
  47. package/src/suppress.ts +13 -7
  48. package/src/version.ts +25 -1
  49. package/src/workflows/audit.ts +96 -26
  50. package/src/workflows/format.ts +51 -11
  51. package/presets/alchemy/data/parameterized-sql.md +0 -17
  52. package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -19
  53. package/presets/effect/basics/external-calls-are-resilient.md +0 -21
package/src/install.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, copyFile, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, sep } from "node:path";
5
+ import { ADHERE_DIRECTORY, type RuleId } from "#config.ts";
6
+ import { parseRuleMarkdown } from "#markdown.ts";
7
+ import { Effect } from "effect";
8
+
9
+ /**
10
+ * A GitHub repo whose `.adhere/` rules another repo copies: `org/repo`, then
11
+ * optionally a topic or a rule's id, then optionally `#` and a branch or tag,
12
+ * as in `darkmatter/standards/data/brand-ports#v3`.
13
+ */
14
+ export interface Source {
15
+ readonly repo: string;
16
+ /** A rule id or a topic, `data/brand-ports` or `data`; empty for every rule. */
17
+ readonly rule: string;
18
+ readonly ref?: string;
19
+ }
20
+
21
+ export const parseSource = (spec: string): Source | undefined => {
22
+ const [path = "", ref] = spec.split("#");
23
+ const [org, repo, ...rule] = path.split("/").filter((segment) => segment.length > 0);
24
+ if (org === undefined || repo === undefined || ref === "") return undefined;
25
+ return { repo: `${org}/${repo}`, rule: rule.join("/"), ...(ref === undefined ? {} : { ref }) };
26
+ };
27
+
28
+ const failed = (what: string) => (cause: unknown) =>
29
+ new Error(`${what}: ${cause instanceof Error ? cause.message : String(cause)}`);
30
+
31
+ /**
32
+ * A shallow clone in a temporary directory, deleted when the scope closes.
33
+ * git, rather than GitHub's API, so a private repo is reached with whatever
34
+ * credentials git already has, and `url.<base>.insteadOf` can point
35
+ * github.com elsewhere, such as at SSH.
36
+ */
37
+ const cloned = (source: Source) =>
38
+ Effect.acquireRelease(
39
+ Effect.tryPromise({
40
+ try: async () => {
41
+ const directory = await mkdtemp(join(tmpdir(), "adhere-install-"));
42
+ const branch = source.ref === undefined ? [] : ["--branch", source.ref];
43
+ const url = `https://github.com/${source.repo}.git`;
44
+ await new Promise<void>((resolve, reject) => {
45
+ let stderr = "";
46
+ spawn("git", ["clone", "--quiet", "--depth", "1", ...branch, url, directory], {
47
+ stdio: ["inherit", "ignore", "pipe"],
48
+ })
49
+ .on("error", reject)
50
+ .on("exit", (code) =>
51
+ code === 0
52
+ ? resolve()
53
+ : reject(new Error(stderr.trim() || `git exited with ${code}`)),
54
+ )
55
+ .stderr?.on("data", (chunk) => {
56
+ stderr += chunk;
57
+ });
58
+ });
59
+ return directory;
60
+ },
61
+ catch: failed(`Could not clone ${source.repo}`),
62
+ }),
63
+ (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
64
+ );
65
+
66
+ /** The repo's root `.adhere/` rule files by id, as lint reads them there: every `*.md` outside `cache/`. */
67
+ const rulesIn = (source: Source, clone: string) =>
68
+ Effect.tryPromise({
69
+ try: async () => {
70
+ const directory = join(clone, ADHERE_DIRECTORY);
71
+ const entries = await readdir(directory, { recursive: true }).catch(() => []);
72
+ const ids = entries
73
+ .filter((entry) => entry.endsWith(".md") && !entry.startsWith(`cache${sep}`))
74
+ .map((entry): RuleId => entry.slice(0, -".md".length).split(sep).join("/"))
75
+ .filter(
76
+ (id) => source.rule === "" || id === source.rule || id.startsWith(`${source.rule}/`),
77
+ )
78
+ .sort();
79
+ if (ids.length === 0) {
80
+ throw new Error(
81
+ source.rule === ""
82
+ ? `it has no rules in ${ADHERE_DIRECTORY}/`
83
+ : `it has no rule or topic ${source.rule} in ${ADHERE_DIRECTORY}/`,
84
+ );
85
+ }
86
+ return ids.map((id) => ({ id, file: join(directory, `${id}.md`) }));
87
+ },
88
+ catch: failed(`Nothing to copy from ${source.repo}`),
89
+ });
90
+
91
+ /** Each rule a source names, with its description. */
92
+ export const listRules = (source: Source) =>
93
+ Effect.scoped(
94
+ Effect.gen(function* () {
95
+ const rules = yield* rulesIn(source, yield* cloned(source));
96
+ return yield* Effect.forEach(rules, ({ id, file }) =>
97
+ Effect.gen(function* () {
98
+ const text = yield* Effect.tryPromise({
99
+ try: () => readFile(file, "utf8"),
100
+ catch: failed(`Could not read ${id}`),
101
+ });
102
+ const rule = yield* parseRuleMarkdown(text, `${source.repo}/${id}.md`);
103
+ return { id, description: rule.description };
104
+ }),
105
+ );
106
+ }),
107
+ );
108
+
109
+ export interface InstallResult {
110
+ readonly created: ReadonlyArray<string>;
111
+ readonly skipped: ReadonlyArray<string>;
112
+ }
113
+
114
+ /**
115
+ * Copies the rules a source names into `root`'s `.adhere/` under the source's
116
+ * org and repo, so `data/ports` from acme/rules is the rule
117
+ * `acme/rules/data/ports`, and two sources' rules never collide. They are the
118
+ * project's own rules from then on. As with init, a file already there is
119
+ * skipped unless `force`.
120
+ */
121
+ export const installRules = (root: string, source: Source, options: { readonly force?: boolean }) =>
122
+ Effect.scoped(
123
+ Effect.gen(function* () {
124
+ const rules = yield* rulesIn(source, yield* cloned(source));
125
+ const created: Array<string> = [];
126
+ const skipped: Array<string> = [];
127
+ for (const { id, file } of rules) {
128
+ const path = `${ADHERE_DIRECTORY}/${source.repo}/${id}.md`;
129
+ const target = join(root, path);
130
+ const copied = yield* Effect.tryPromise({
131
+ try: async () => {
132
+ const exists = await access(target).then(
133
+ () => true,
134
+ () => false,
135
+ );
136
+ if (exists && options.force !== true) return false;
137
+ await mkdir(dirname(target), { recursive: true });
138
+ await copyFile(file, target);
139
+ return true;
140
+ },
141
+ catch: failed(`Could not write ${path}`),
142
+ });
143
+ (copied ? created : skipped).push(path);
144
+ }
145
+ return { created, skipped } satisfies InstallResult;
146
+ }),
147
+ );
package/src/markdown.ts CHANGED
@@ -32,12 +32,18 @@ import { Effect, Schema } from "effect";
32
32
  * so is `tests`: `only` for a rule about test files, which rules otherwise
33
33
  * skip, and `include` for a rule that judges them too. So is `level`:
34
34
  * `warning` reports the rule's findings as warnings, which do not fail a run.
35
+ * `appliesTo` and `excludeIf`, the rule's scope, are JSON arrays of strings on
36
+ * one line, as in `excludeIf: ["a type that mirrors a third-party format"]`.
35
37
  */
38
+ const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
39
+
36
40
  const FrontMatter = Schema.Struct({
37
41
  description: Schema.String,
38
42
  threshold: Schema.optionalKey(Schema.FiniteFromString),
39
43
  tests: Schema.optionalKey(Schema.Literals(["only", "include"])),
40
44
  level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
45
+ appliesTo: Schema.optionalKey(Matchers),
46
+ excludeIf: Schema.optionalKey(Matchers),
41
47
  });
42
48
 
43
49
  const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
package/src/presets.ts CHANGED
@@ -23,6 +23,9 @@ const presetsRoot = isStandaloneExecutable
23
23
  const topics = {
24
24
  effect: ["basics", "config", "data", "errors", "services", "testing"],
25
25
  alchemy: ["apis", "data", "durable", "providers", "resources", "runtime", "secrets", "security"],
26
+ typescript: ["async", "design", "errors", "testing", "types"],
27
+ react: ["effects", "server", "state"],
28
+ security: [],
26
29
  } as const;
27
30
 
28
31
  type Topics = typeof topics;
@@ -57,6 +60,9 @@ export const presetOf = (name: PresetName): BuiltInPreset => {
57
60
  export const presets: Readonly<Record<WholePreset, BuiltInPreset>> = {
58
61
  effect: presetOf("effect"),
59
62
  alchemy: presetOf("alchemy"),
63
+ typescript: presetOf("typescript"),
64
+ react: presetOf("react"),
65
+ security: presetOf("security"),
60
66
  };
61
67
 
62
68
  /** The directory each whole preset reads, for checking `topics` against it. */
package/src/rules.ts CHANGED
@@ -27,7 +27,7 @@ export interface RuleEntry {
27
27
 
28
28
  /**
29
29
  * A rule's id as output shows it: a preset's rule with the preset's name
30
- * first, as in effect/basics/external-calls-are-resilient, so a report that
30
+ * first, as in effect/basics/gen-for-sequencing, so a report that
31
31
  * mixes presets with the project's own rules says where each came from. The
32
32
  * project's own rules show their `.adhere/` ids as they are.
33
33
  */
@@ -1,11 +1,30 @@
1
1
  import { CACHE_DIRECTORY } from "#config.ts";
2
2
  import { Context, Crypto, Effect, FileSystem, Layer, Option, Path, Record, Schema } from "effect";
3
3
 
4
+ /** Jev's probability for each of a rule's `appliesTo` and `excludeIf` matchers, when it has them. */
5
+ const MatcherFields = {
6
+ appliesTo: Schema.optionalKey(Schema.Array(Schema.Finite)),
7
+ excludeIf: Schema.optionalKey(Schema.Array(Schema.Finite)),
8
+ };
9
+
10
+ /** Lines of a file, first to last. */
11
+ const Lines = Schema.Struct({ first: Schema.Finite, last: Schema.Finite });
12
+ /** The lines of the section a finding is in, once located. */
13
+ const Section = Schema.optionalKey(Lines);
14
+ /** The lines in its section the code that breaks the rule starts and ends on, when Jev named them. */
15
+ const Located = Schema.optionalKey(Lines);
16
+
17
+ /** Jev's probability, asked with the section, that the file shows enough to decide the rule. */
18
+ const Sufficiency = Schema.optionalKey(Schema.Finite);
19
+
4
20
  /** A rule's judgment as a run holds it: Jev's answer, with the fingerprint of the rule's text. */
5
21
  export const Judgment = Schema.Struct({
6
22
  fingerprint: Schema.String,
7
23
  probability: Schema.Finite,
8
- line: Schema.optionalKey(Schema.Finite),
24
+ section: Section,
25
+ lines: Located,
26
+ sufficiency: Sufficiency,
27
+ ...MatcherFields,
9
28
  });
10
29
  export interface Judgment extends Schema.Schema.Type<typeof Judgment> {}
11
30
 
@@ -21,10 +40,17 @@ export const CacheEntry = Schema.Struct({
21
40
  });
22
41
  export interface CacheEntry extends Schema.Schema.Type<typeof CacheEntry> {}
23
42
 
24
- /** Jev's answer about some code for one rule text: the probability, and the line once located. */
43
+ /**
44
+ * Jev's answer about some code for one rule text: the probability, the
45
+ * section, lines, and sufficiency once located, and its matchers' scores. An answer from before
46
+ * sections held a line instead, under a fingerprint no rule has now.
47
+ */
25
48
  export const Answer = Schema.Struct({
26
49
  probability: Schema.Finite,
27
- line: Schema.optionalKey(Schema.Finite),
50
+ section: Section,
51
+ lines: Located,
52
+ sufficiency: Sufficiency,
53
+ ...MatcherFields,
28
54
  });
29
55
  export interface Answer extends Schema.Schema.Type<typeof Answer> {}
30
56
 
@@ -98,13 +124,13 @@ const sorted = <A>(record: Readonly<Record<string, A>>): Record<string, A> =>
98
124
 
99
125
  const textOf = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
100
126
 
101
- /** One answer per rule text: one that has its line over one that has not, otherwise the first. */
127
+ /** One answer per rule text: one that has its section over one that has not, otherwise the first. */
102
128
  const unionOf = (entries: ReadonlyArray<Answers>): Answers => {
103
129
  const union: Record<string, Answer> = {};
104
130
  for (const answers of entries) {
105
131
  for (const [fingerprint, answer] of Object.entries(answers)) {
106
132
  const held = union[fingerprint];
107
- if (held === undefined || (held.line === undefined && answer.line !== undefined)) {
133
+ if (held === undefined || (held.section === undefined && answer.section !== undefined)) {
108
134
  union[fingerprint] = answer;
109
135
  }
110
136
  }
@@ -1,10 +1,11 @@
1
1
  import type { RuleId } from "#config.ts";
2
+ import { sectionsOf } from "#excerpt.ts";
2
3
  import { AdhereConfig } from "#services/AdhereConfig.ts";
3
4
  import { Credentials } from "#services/Credentials.ts";
4
5
  import {
5
- blockBody,
6
6
  type Body,
7
7
  type ComparedRule,
8
+ edgeKey,
8
9
  conflictBody,
9
10
  contradictBody,
10
11
  Jev,
@@ -16,15 +17,27 @@ import {
16
17
  type Lines,
17
18
  linterKey,
18
19
  locateBody,
20
+ matcherKey,
21
+ type MatcherScores,
19
22
  namedPairs,
20
- needsBlocks,
21
23
  type Pair,
22
24
  pairProbability,
23
25
  requestsOf,
24
26
  type Rules,
27
+ sufficiencyKey,
25
28
  tokensOf,
26
29
  } from "#services/Jev.ts";
27
- import { type Cause, Duration, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
30
+ import {
31
+ type Cause,
32
+ Duration,
33
+ Effect,
34
+ Layer,
35
+ Option,
36
+ Record,
37
+ Result,
38
+ Schedule,
39
+ Schema,
40
+ } from "effect";
28
41
  import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
29
42
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
30
43
  import { RateLimiter } from "effect/unstable/persistence";
@@ -37,6 +50,16 @@ const NoulAnswers = Schema.Struct({
37
50
  const ChoiceAnswers = Schema.Struct({
38
51
  answers: Schema.Record(Schema.String, Schema.Struct({ choice: Schema.String })),
39
52
  });
53
+ /** A locate request's answers: a choice per rule, and a noul per sufficiency question. */
54
+ const LocateAnswers = Schema.Struct({
55
+ answers: Schema.Record(
56
+ Schema.String,
57
+ Schema.Struct({
58
+ choice: Schema.optionalKey(Schema.String),
59
+ noul: Schema.optionalKey(Schema.Finite),
60
+ }),
61
+ ),
62
+ });
40
63
 
41
64
  const refused = (message: string) => JevUnavailable.make({ message });
42
65
 
@@ -219,22 +242,64 @@ export const JevLive = Layer.effect(Jev)(
219
242
  return answer === undefined ? [] : [[id, answer.noul] as const];
220
243
  }),
221
244
  );
245
+ // A matcher Jev left unanswered counts as a no.
246
+ const scored = (
247
+ kind: keyof MatcherScores,
248
+ id: RuleId,
249
+ matchers: ReadonlyArray<string> = [],
250
+ ) => matchers.map((_, index) => answers[matcherKey(kind, id, index)]?.noul ?? 0);
251
+ const matchers: Record<RuleId, MatcherScores> = Record.fromEntries(
252
+ Object.entries(rules).flatMap(([id, rule]) =>
253
+ rule.appliesTo === undefined && rule.excludeIf === undefined
254
+ ? []
255
+ : [
256
+ [
257
+ id,
258
+ {
259
+ appliesTo: scored("appliesTo", id, rule.appliesTo),
260
+ excludeIf: scored("excludeIf", id, rule.excludeIf),
261
+ },
262
+ ] as const,
263
+ ],
264
+ ),
265
+ );
222
266
  return {
223
267
  probabilities: answered(Object.keys(rules), (id) => id),
224
268
  linter: answered(sampled, linterKey),
269
+ ...(Record.isEmptyRecord(matchers) ? {} : { matchers }),
225
270
  } satisfies Judged;
226
271
  });
227
272
 
228
- const chosen = (answers: Readonly<Record<string, { choice: string }>>) =>
229
- Record.map(answers, (answer) => Number(answer.choice));
230
-
231
273
  const locate = Effect.fn("Jev.locate")(function* (lines: Lines, rules: Rules) {
232
- const blocks = needsBlocks(lines)
233
- ? chosen(yield* answersTo("blocks", blockBody(config.model, lines, rules), ChoiceAnswers))
234
- : undefined;
235
- return chosen(
236
- yield* answersTo("locate", locateBody(config.model, lines, rules, blocks), ChoiceAnswers),
274
+ const sections = sectionsOf(lines);
275
+ const answers = yield* answersTo(
276
+ "locate",
277
+ locateBody(config.model, lines, rules),
278
+ LocateAnswers,
237
279
  );
280
+ // A file of one section has every finding in it. An answer that names no section,
281
+ // or none at all, which Jev should not give, leaves the rule unlocated; lines outside
282
+ // the section leave it without lines.
283
+ return Record.filterMap(rules, (_, id) => {
284
+ const chosen = sections.length === 1 ? 1 : Number(answers[id]?.choice);
285
+ const section = sections[chosen - 1];
286
+ const sufficiency = answers[sufficiencyKey(id)]?.noul;
287
+ if (section === undefined || sufficiency === undefined) return Result.failVoid;
288
+ const [start, end] = (["start", "end"] as const).map((edge) =>
289
+ Number(answers[edgeKey(edge, id, chosen)]?.choice),
290
+ );
291
+ const within = (line: number | undefined): line is number =>
292
+ line !== undefined && section.first <= line && line <= section.last;
293
+ return Result.succeed(
294
+ within(start) && within(end)
295
+ ? {
296
+ section,
297
+ sufficiency,
298
+ lines: { first: Math.min(start, end), last: Math.max(start, end) },
299
+ }
300
+ : { section, sufficiency },
301
+ );
302
+ });
238
303
  });
239
304
 
240
305
  const conflicts = Effect.fn("Jev.conflicts")(function* (