@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
@@ -0,0 +1,89 @@
1
+ import type { Rule } from "#config.ts";
2
+ import type { RuleEntry } from "#rules.ts";
3
+ import { AuditCache, sha256, type Tally } from "#services/AuditCache.ts";
4
+ import { linterQuestion } from "#services/Jev.ts";
5
+ import { Effect } from "effect";
6
+
7
+ /**
8
+ * The linter check asks, beside a rule's judge question, whether a regular
9
+ * linter could have decided the file against the rule. The answer is about
10
+ * the rule, so lint asks it on this many files per rule and then stops.
11
+ */
12
+ export const SAMPLE = 10;
13
+
14
+ /** A tally's key: the model and the question, which carries the rule, so an edited rule starts over. */
15
+ export const tallyKeyOf = (model: string, rule: Rule) =>
16
+ sha256(`${model}\u0000${JSON.stringify(linterQuestion(rule))}`);
17
+
18
+ /** A rule and its linter check so far: the files lint asked on, and how many Jev said a linter could check. */
19
+ export interface Tallied {
20
+ readonly entry: RuleEntry;
21
+ readonly asked: number;
22
+ readonly flagged: number;
23
+ }
24
+
25
+ export const talliedOf = (entry: RuleEntry, tally: Tally | undefined): Tallied => {
26
+ const answers = Object.values(tally?.files ?? {});
27
+ return { entry, asked: answers.length, flagged: answers.filter((p) => p > 0.5).length };
28
+ };
29
+
30
+ /** The project's rules with their tallies. A preset's rules are left out: they are not the user's to change. */
31
+ export const tallyProjectRules = Effect.fn("mechanical.tally")(function* (
32
+ entries: ReadonlyArray<RuleEntry>,
33
+ model: string,
34
+ ) {
35
+ const cache = yield* AuditCache;
36
+ return yield* Effect.forEach(
37
+ entries.filter((entry) => entry.preset === undefined),
38
+ (entry) =>
39
+ Effect.map(Effect.flatMap(tallyKeyOf(model, entry.rule), cache.tally), (tally) =>
40
+ talliedOf(entry, tally),
41
+ ),
42
+ );
43
+ });
44
+
45
+ /**
46
+ * Of a full sample, the share Jev said a linter could check: from 0.7 a
47
+ * linter should probably check the rule, and from 0.3 the rule reads
48
+ * differently from file to file.
49
+ */
50
+ const LINTER = 0.7;
51
+ const MIXED = 0.3;
52
+
53
+ const displayPath = (file: string, root: string): string =>
54
+ file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file;
55
+
56
+ /** One flagged rule's advice, from its tally. */
57
+ const adviceOf = ({ asked, flagged }: Tallied): string =>
58
+ flagged >= LINTER * asked
59
+ ? `Flagged on ${flagged} of ${asked} files: a regular linter should probably check this rule.`
60
+ : `Flagged on ${flagged} of ${asked} files: say more precisely what the rule applies to.`;
61
+
62
+ /** The linter check's part of `adhere validate`'s report: each rule it flags, then any it has too few answers for. */
63
+ export const formatLinterCheck = (
64
+ tallied: ReadonlyArray<Tallied>,
65
+ root: string,
66
+ ): ReadonlyArray<string> => {
67
+ if (tallied.length === 0) return [];
68
+ const sampled = tallied.filter((one) => one.asked >= SAMPLE);
69
+ const flagged = sampled.filter((one) => one.flagged >= MIXED * one.asked);
70
+ const waiting = tallied.length - sampled.length;
71
+ return [
72
+ ...(flagged.length === 0
73
+ ? sampled.length === 0
74
+ ? []
75
+ : ["The linter check flags no rule."]
76
+ : [
77
+ `${flagged.length} ${flagged.length === 1 ? "rule" : "rules"} flagged by the linter check:`,
78
+ ...flagged.flatMap((one) => [
79
+ ` ${one.entry.id}${one.entry.file === undefined ? "" : ` (${displayPath(one.entry.file, root)})`}`,
80
+ ` ${adviceOf(one)}`,
81
+ ]),
82
+ ]),
83
+ ...(waiting === 0
84
+ ? []
85
+ : [
86
+ `${waiting} ${waiting === 1 ? "rule has" : "rules have"} fewer than ${SAMPLE} answers from lint yet, which asks as it judges files.`,
87
+ ]),
88
+ ];
89
+ };
package/src/presets.ts CHANGED
@@ -7,17 +7,54 @@ import type { Preset } from "#config.ts";
7
7
  * is the check Bun's docs give for this. Measured on Bun 1.4. The `typeof`
8
8
  * guard is for Vitest, which imports this module on Node.
9
9
  */
10
- const isStandaloneExecutable =
11
- typeof Bun !== "undefined" && Bun.isStandaloneExecutable;
10
+ const isStandaloneExecutable = typeof Bun !== "undefined" && Bun.isStandaloneExecutable;
12
11
 
13
12
  const presetsRoot = isStandaloneExecutable
14
13
  ? new URL("./presets/", import.meta.url)
15
14
  : new URL("../presets/", import.meta.url);
16
15
 
17
- /** Built-in rule sets, each a directory of Markdown rule files in `presets/`. */
18
- export const presets = {
19
- effect: { rules: new URL("effect/", presetsRoot) },
20
- } as const satisfies Record<string, Preset>;
16
+ /**
17
+ * Built-in rule sets, each a directory of Markdown rule files in `presets/`,
18
+ * and the topics each divides into: its subdirectories.
19
+ */
20
+ const topics = {
21
+ effect: ["basics", "config", "data", "errors", "services", "testing"],
22
+ alchemy: ["apis", "data", "durable", "providers", "resources", "runtime", "secrets", "security"],
23
+ } as const;
24
+
25
+ type Topics = typeof topics;
26
+ type WholePreset = keyof Topics;
27
+
28
+ /** A whole preset, `effect`, or one of its topics, `effect/basics`. */
29
+ export type PresetName = { [P in WholePreset]: P | `${P}/${Topics[P][number]}` }[WholePreset];
30
+
31
+ export const presetNames: ReadonlyArray<PresetName> = Object.entries(topics).flatMap(
32
+ ([preset, names]) => [preset, ...names.map((topic) => `${preset}/${topic}`)],
33
+ ) as ReadonlyArray<PresetName>;
34
+
35
+ /** A built-in preset. With `topic`, only the rules under that subdirectory apply. */
36
+ export interface BuiltInPreset extends Preset {
37
+ readonly rules: URL;
38
+ readonly topic?: string;
39
+ }
40
+
41
+ /**
42
+ * A topic's rules keep the ids they have in the whole preset, so
43
+ * `effect/basics` and `effect` judge `basics/gen-for-sequencing` alike and
44
+ * share its cached answers.
45
+ */
46
+ export const presetOf = (name: PresetName): BuiltInPreset => {
47
+ const [preset = name, topic] = name.split("/");
48
+ return {
49
+ rules: new URL(`${preset}/`, presetsRoot),
50
+ ...(topic === undefined ? {} : { topic }),
51
+ };
52
+ };
53
+
54
+ export const presets: Readonly<Record<WholePreset, BuiltInPreset>> = {
55
+ effect: presetOf("effect"),
56
+ alchemy: presetOf("alchemy"),
57
+ };
21
58
 
22
- export const presetNames = ["effect"] as const satisfies ReadonlyArray<keyof typeof presets>;
23
- export type PresetName = (typeof presetNames)[number];
59
+ /** The directory each whole preset reads, for checking `topics` against it. */
60
+ export const topicsOf = (preset: WholePreset): ReadonlyArray<string> => topics[preset];
package/src/rules.ts CHANGED
@@ -18,6 +18,8 @@ export interface RuleEntry {
18
18
  readonly rule: Rule;
19
19
  readonly scope: string;
20
20
  readonly file?: string;
21
+ /** The built-in rule set the rule came from, when it is not the project's own. */
22
+ readonly preset?: string;
21
23
  }
22
24
 
23
25
  export type RuleSet = ReadonlyArray<RuleEntry>;
@@ -11,7 +11,7 @@ import {
11
11
  type ResolvedConfig,
12
12
  resolveConfig,
13
13
  } from "#config.ts";
14
- import { type PresetName, presets } from "#presets.ts";
14
+ import { type PresetName, presetOf } from "#presets.ts";
15
15
  import {
16
16
  applicableRules,
17
17
  globalRuleSet,
@@ -21,10 +21,9 @@ import {
21
21
  } from "#rules.ts";
22
22
  import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
23
23
 
24
- export class AdhereConfig extends Context.Service<
25
- AdhereConfig,
26
- ResolvedConfig
27
- >()("@drkmttr/adhere/services/AdhereConfig") {}
24
+ export class AdhereConfig extends Context.Service<AdhereConfig, ResolvedConfig>()(
25
+ "@drkmttr/adhere/services/AdhereConfig",
26
+ ) {}
28
27
 
29
28
  const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
30
29
  const path = yield* Path.Path;
@@ -44,8 +43,10 @@ const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
44
43
  });
45
44
 
46
45
  const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetName, base: string) {
47
- const preset: Preset = presets[name];
48
- const rules = yield* materializeRules(preset.rules, base);
46
+ const { topic, ...preset } = presetOf(name);
47
+ const all = yield* materializeRules(preset.rules, base);
48
+ const rules =
49
+ topic === undefined ? all : Record.filter(all, (_, id) => id.startsWith(`${topic}/`));
49
50
  return [name, { ...preset, rules } satisfies Loaded<Preset>] as const;
50
51
  });
51
52
 
@@ -72,14 +73,12 @@ export const AdhereConfigLive = (overrides: Overrides) =>
72
73
  });
73
74
  }
74
75
  const configFile = found[0];
75
- const hasPreset =
76
- overrides.presets !== undefined && overrides.presets.length > 0;
76
+ const hasPreset = overrides.presets !== undefined && overrides.presets.length > 0;
77
77
  const config: Decoded =
78
78
  configFile === undefined
79
79
  ? yield* decodeConfig({})
80
80
  : yield* loadConfigFile(path.join(cwd, configFile));
81
- const discoveredEntries =
82
- config.rules === undefined ? yield* loadAdhereRuleSet(cwd) : [];
81
+ const discoveredEntries = config.rules === undefined ? yield* loadAdhereRuleSet(cwd) : [];
83
82
  const hasProjectRules = discoveredEntries.length > 0;
84
83
  if (configFile === undefined && !hasProjectRules && !hasPreset) {
85
84
  return yield* ConfigUnavailable.make({
@@ -89,18 +88,17 @@ export const AdhereConfigLive = (overrides: Overrides) =>
89
88
  const projectEntries: RuleSet =
90
89
  config.rules === undefined
91
90
  ? discoveredEntries
92
- : globalRuleSet(
93
- yield* materializeRules(config.rules ?? {}, cwd),
94
- cwd,
95
- );
91
+ : globalRuleSet(yield* materializeRules(config.rules ?? {}, cwd), cwd);
96
92
  const rules = applicableRules(cwd, projectEntries);
97
- const registry = yield* Effect.forEach(
98
- presetsOf(config, overrides),
99
- (name) => loadPreset(name, cwd),
93
+ const registry = yield* Effect.forEach(presetsOf(config, overrides), (name) =>
94
+ loadPreset(name, cwd),
100
95
  ).pipe(Effect.map(Record.fromEntries));
101
96
  const resolved = resolveConfig({ ...config, rules }, overrides, registry);
102
- const presetEntries = Object.entries(registry).flatMap(([, preset]) =>
103
- globalRuleSet(preset.rules, cwd),
97
+ const presetEntries = Object.entries(registry).flatMap(([name, preset]) =>
98
+ globalRuleSet(preset.rules, cwd).map((entry) => ({ ...entry, preset: name })),
99
+ );
100
+ yield* Effect.logDebug(
101
+ `config: ${configFile ?? "no config file"}; presets ${Object.keys(registry).join(", ") || "none"}; ${presetEntries.length} preset and ${projectEntries.length} project rules; model ${resolved.model}, threshold ${resolved.threshold}${resolved.rpm === undefined ? "" : `, at most ${resolved.rpm} requests a minute`}`,
104
102
  );
105
103
  return AdhereConfig.of({
106
104
  ...resolved,
@@ -1,21 +1,60 @@
1
1
  import { CACHE_DIRECTORY } from "#config.ts";
2
- import { Context, Crypto, Effect, Layer, Option, Path, Schema } from "effect";
3
- import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore";
2
+ import { Context, Crypto, Effect, FileSystem, Layer, Option, Path, Record, Schema } from "effect";
4
3
 
4
+ /** A rule's judgment as a run holds it: Jev's answer, with the fingerprint of the rule's text. */
5
5
  export const Judgment = Schema.Struct({
6
6
  fingerprint: Schema.String,
7
7
  probability: Schema.Finite,
8
8
  line: Schema.optionalKey(Schema.Finite),
9
- snippet: Schema.optionalKey(Schema.String),
10
9
  });
11
10
  export interface Judgment extends Schema.Schema.Type<typeof Judgment> {}
12
11
 
12
+ /**
13
+ * An entry as adhere wrote it before the cache was keyed by content: one per
14
+ * file, under the hash of the file's absolute path. Read only to carry its
15
+ * judgments over. Entries written before the report read its excerpt from the
16
+ * file also hold the line's text, as `snippet`; decoding drops it.
17
+ */
13
18
  export const CacheEntry = Schema.Struct({
14
19
  hash: Schema.String,
15
20
  judgments: Schema.Record(Schema.String, Judgment),
16
21
  });
17
22
  export interface CacheEntry extends Schema.Schema.Type<typeof CacheEntry> {}
18
23
 
24
+ /** Jev's answer about some code for one rule text: the probability, and the line once located. */
25
+ export const Answer = Schema.Struct({
26
+ probability: Schema.Finite,
27
+ line: Schema.optionalKey(Schema.Finite),
28
+ });
29
+ export interface Answer extends Schema.Schema.Type<typeof Answer> {}
30
+
31
+ /** Answers about one file's content, each under the fingerprint of the rule text it answers. */
32
+ export type Answers = Readonly<Record<string, Answer>>;
33
+
34
+ const Entry = Schema.Struct({ answers: Schema.Record(Schema.String, Answer) });
35
+ const decodeEntry = Schema.decodeUnknownOption(Schema.fromJsonString(Entry));
36
+ const decodeOldEntry = Schema.decodeUnknownOption(Schema.fromJsonString(CacheEntry));
37
+
38
+ /**
39
+ * A rule's linter check so far: per file lint asked it on, Jev's probability
40
+ * that a regular linter could have decided the file against the rule.
41
+ */
42
+ export const Tally = Schema.Struct({
43
+ files: Schema.Record(Schema.String, Schema.Finite),
44
+ });
45
+ export interface Tally extends Schema.Schema.Type<typeof Tally> {}
46
+ const decodeTally = Schema.decodeUnknownOption(Schema.fromJsonString(Tally));
47
+
48
+ /** What a run that read every file still needs from the cache. */
49
+ export interface Live {
50
+ /** The hash of each file's content. */
51
+ readonly hashes: ReadonlySet<string>;
52
+ /** The fingerprint of each rule text asked about a file. */
53
+ readonly fingerprints: ReadonlySet<string>;
54
+ /** The key of each rule's tally. */
55
+ readonly tallies: ReadonlySet<string>;
56
+ }
57
+
19
58
  const hexOf = (bytes: Uint8Array): string =>
20
59
  Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
21
60
 
@@ -25,38 +64,248 @@ export const sha256 = Effect.fn("AuditCache.sha256")(function* (text: string) {
25
64
  return hexOf(digest);
26
65
  });
27
66
 
67
+ /** Judgments by rule, as the answers the cache keeps: by the fingerprint of each rule's text. */
68
+ export const answersOf = (judgments: Readonly<Record<string, Judgment>>): Answers =>
69
+ Object.fromEntries(
70
+ Object.values(judgments).map(({ fingerprint, ...answer }) => [fingerprint, answer]),
71
+ );
72
+
28
73
  export class AuditCache extends Context.Service<
29
74
  AuditCache,
30
75
  {
31
- readonly get: (path: string) => Effect.Effect<CacheEntry | undefined>;
32
- readonly put: (path: string, entry: CacheEntry) => Effect.Effect<void>;
76
+ /**
77
+ * Answers about content with this hash. `path` is where that content is
78
+ * now, to carry over an entry written before the cache was keyed by content.
79
+ */
80
+ readonly get: (hash: string, path: string) => Effect.Effect<Answers>;
81
+ /** Keeps answers about content with this hash, beside the ones already kept. */
82
+ readonly put: (hash: string, answers: Answers) => Effect.Effect<void>;
83
+ /** A rule's tally, under a key that changes with the rule's text. */
84
+ readonly tally: (key: string) => Effect.Effect<Tally | undefined>;
85
+ readonly putTally: (key: string, tally: Tally) => Effect.Effect<void>;
86
+ /**
87
+ * Deletes what `live` does not need, and folds the rest into one file per
88
+ * content and per tally. Only after a run that read every file: it cannot
89
+ * tell what the files it did not read need. Succeeds with the files deleted.
90
+ */
91
+ readonly prune: (live: Live) => Effect.Effect<number>;
33
92
  }
34
93
  >()("@drkmttr/adhere/services/AuditCache") {}
35
94
 
36
- const openStore = Effect.fn("AuditCache.openStore")(function* (directory: string) {
37
- const context = yield* Layer.build(KeyValueStore.layerFileSystem(directory)).pipe(
38
- Effect.catchCause(() => Layer.build(KeyValueStore.layerMemory)),
39
- );
40
- return Context.get(context, KeyValueStore.KeyValueStore);
41
- });
95
+ /** Keys in order, so the same content always writes the same text. */
96
+ const sorted = <A>(record: Readonly<Record<string, A>>): Record<string, A> =>
97
+ Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
98
+
99
+ const textOf = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
100
+
101
+ /** One answer per rule text: one that has its line over one that has not, otherwise the first. */
102
+ const unionOf = (entries: ReadonlyArray<Answers>): Answers => {
103
+ const union: Record<string, Answer> = {};
104
+ for (const answers of entries) {
105
+ for (const [fingerprint, answer] of Object.entries(answers)) {
106
+ const held = union[fingerprint];
107
+ if (held === undefined || (held.line === undefined && answer.line !== undefined)) {
108
+ union[fingerprint] = answer;
109
+ }
110
+ }
111
+ }
112
+ return union;
113
+ };
42
114
 
115
+ /** One answer per file, the first. */
116
+ const tallyUnionOf = (tallies: ReadonlyArray<Tally>): Tally => {
117
+ const files: Record<string, number> = {};
118
+ for (const tally of tallies) {
119
+ for (const [file, probability] of Object.entries(tally.files)) files[file] ??= probability;
120
+ }
121
+ return { files };
122
+ };
123
+
124
+ /** A group's file: the group, then the hash of the file's text. */
125
+ const NAME = /^([^.]+)\.[0-9a-f]{16}\.json$/;
126
+ /** A file from the layout before content keys: the hash of a path or of a tally's key. */
127
+ const OLD_NAME = /^[0-9a-f]{64}$/;
128
+
129
+ /**
130
+ * `.adhere/cache/`, which is meant to be committed. `files/` holds answers by
131
+ * the content they are about, and `tallies/` the linter check's tallies, with
132
+ * paths from the working directory. A file is written once and never
133
+ * rewritten: it is named for its group, a content hash or a tally key, and for
134
+ * the hash of its own text. Two branches that add to a group add different
135
+ * files, or the same one, so git merges them without a conflict. Reading a
136
+ * group takes the union of its files, and pruning folds them back into one.
137
+ *
138
+ * A cache must never fail a run: whatever cannot be read counts as missing,
139
+ * and whatever cannot be written is left unwritten.
140
+ */
43
141
  export const AuditCacheLive = Layer.effect(AuditCache)(
44
142
  Effect.gen(function* () {
143
+ const fs = yield* FileSystem.FileSystem;
45
144
  const path = yield* Path.Path;
46
- const store = yield* openStore(path.join(path.resolve(), CACHE_DIRECTORY));
47
- const entries = KeyValueStore.toSchemaStore(store, CacheEntry);
48
145
  const crypto = yield* Crypto.Crypto;
49
- // Hashed keys: a raw path would name the cache file like a source file.
50
- const keyOf = (filePath: string) =>
51
- sha256(filePath).pipe(Effect.provideService(Crypto.Crypto, crypto));
52
- return AuditCache.of({
53
- get: (filePath) =>
54
- Effect.flatMap(keyOf(filePath), (key) => entries.get(key)).pipe(
55
- Effect.map(Option.getOrUndefined),
56
- Effect.orElseSucceed(() => undefined),
146
+ const root = path.resolve();
147
+ const directory = path.join(root, CACHE_DIRECTORY);
148
+ const hash = (text: string) => sha256(text).pipe(Effect.provideService(Crypto.Crypto, crypto));
149
+
150
+ /** Each group's file names in one folder, listed once and kept current as files come and go. */
151
+ const indexOf = (folder: string) =>
152
+ Effect.cached(
153
+ fs.readDirectory(path.join(directory, folder)).pipe(
154
+ Effect.orElseSucceed((): ReadonlyArray<string> => []),
155
+ Effect.map((names) => {
156
+ const groups = new Map<string, Set<string>>();
157
+ for (const name of names) {
158
+ const group = NAME.exec(name)?.[1];
159
+ if (group !== undefined)
160
+ groups.set(group, (groups.get(group) ?? new Set()).add(name));
161
+ }
162
+ return groups;
163
+ }),
57
164
  ),
58
- put: (filePath, entry) =>
59
- Effect.flatMap(keyOf(filePath), (key) => entries.set(key, entry)).pipe(Effect.ignore),
165
+ );
166
+ const indexes = { files: yield* indexOf("files"), tallies: yield* indexOf("tallies") };
167
+ type Folder = keyof typeof indexes;
168
+
169
+ const read = <A>(decode: (text: string) => Option.Option<A>, file: string) =>
170
+ fs.readFileString(file).pipe(
171
+ Effect.map((text) => Option.getOrUndefined(decode(text))),
172
+ Effect.orElseSucceed(() => undefined),
173
+ );
174
+
175
+ /** A group's files that decode, in name order. */
176
+ const readGroup = Effect.fn("AuditCache.readGroup")(function* <A>(
177
+ folder: Folder,
178
+ group: string,
179
+ decode: (text: string) => Option.Option<A>,
180
+ ) {
181
+ const names = [...((yield* indexes[folder]).get(group) ?? [])].sort();
182
+ const decoded = yield* Effect.forEach(names, (name) =>
183
+ read(decode, path.join(directory, folder, name)),
184
+ );
185
+ return decoded.filter((value): value is A => value !== undefined);
186
+ });
187
+
188
+ const nameOf = (group: string, text: string) =>
189
+ Effect.map(hash(text), (digest) => `${group}.${digest.slice(0, 16)}.json`);
190
+
191
+ /** Adds `value` to a group as a new file, unless the group has that file already. */
192
+ const add = Effect.fn("AuditCache.add")(function* (
193
+ folder: Folder,
194
+ group: string,
195
+ value: unknown,
196
+ ) {
197
+ const text = textOf(value);
198
+ const name = yield* nameOf(group, text);
199
+ const index = yield* indexes[folder];
200
+ const names = index.get(group) ?? new Set<string>();
201
+ if (names.has(name)) return;
202
+ const written = yield* fs
203
+ .makeDirectory(path.join(directory, folder), { recursive: true })
204
+ .pipe(
205
+ Effect.andThen(fs.writeFileString(path.join(directory, folder, name), text)),
206
+ Effect.as(true),
207
+ Effect.orElseSucceed(() => false),
208
+ );
209
+ if (written) index.set(group, names.add(name));
210
+ });
211
+
212
+ const remove = (file: string) =>
213
+ fs.remove(file).pipe(
214
+ Effect.as(1),
215
+ Effect.orElseSucceed(() => 0),
216
+ );
217
+
218
+ /** Replaces a group's files with one holding `value`, or with none. Succeeds with the files deleted. */
219
+ const fold = Effect.fn("AuditCache.fold")(function* (
220
+ folder: Folder,
221
+ group: string,
222
+ value: unknown,
223
+ ) {
224
+ const index = yield* indexes[folder];
225
+ const names = [...(index.get(group) ?? [])];
226
+ const keep = value === undefined ? undefined : yield* nameOf(group, textOf(value));
227
+ if (value !== undefined) yield* add(folder, group, value);
228
+ let deleted = 0;
229
+ for (const name of names) {
230
+ if (name !== keep) deleted += yield* remove(path.join(directory, folder, name));
231
+ }
232
+ if (keep === undefined) index.delete(group);
233
+ else index.set(group, new Set([keep]));
234
+ return deleted;
235
+ });
236
+
237
+ const relativeOf = (tally: Tally): Tally => ({
238
+ files: sorted(
239
+ Record.mapKeys(tally.files, (file) =>
240
+ path.isAbsolute(file) ? path.relative(root, file) : file,
241
+ ),
242
+ ),
243
+ });
244
+
245
+ const get = Effect.fn("AuditCache.get")(function* (contentHash: string, filePath: string) {
246
+ const entries = yield* readGroup("files", contentHash, decodeEntry);
247
+ if (entries.length > 0) return unionOf(entries.map((entry) => entry.answers));
248
+ // Before content keys, a file's entry sat under the hash of its absolute path.
249
+ const old = yield* read(decodeOldEntry, path.join(directory, yield* hash(filePath)));
250
+ if (old === undefined || old.hash !== contentHash) return {};
251
+ const answers = answersOf(old.judgments);
252
+ yield* add("files", contentHash, { answers: sorted(answers) });
253
+ return answers;
254
+ });
255
+
256
+ const tally = Effect.fn("AuditCache.tally")(function* (key: string) {
257
+ const tallies = yield* readGroup("tallies", key, decodeTally);
258
+ if (tallies.length > 0) {
259
+ const { files } = tallyUnionOf(tallies);
260
+ return { files: Record.mapKeys(files, (file) => path.resolve(root, file)) };
261
+ }
262
+ // Before, a tally sat under the hash of its key, with absolute paths.
263
+ const old = yield* read(
264
+ decodeTally,
265
+ path.join(directory, yield* hash(`\u0000tally\u0000${key}`)),
266
+ );
267
+ if (old !== undefined) yield* add("tallies", key, relativeOf(old));
268
+ return old;
269
+ });
270
+
271
+ const prune = Effect.fn("AuditCache.prune")(function* (live: Live) {
272
+ // Tallies still in the layout before content keys move first, so deleting what is left of it loses nothing.
273
+ yield* Effect.forEach(live.tallies, tally, { discard: true });
274
+ let deleted = 0;
275
+ // Folding replaces or removes the group being visited, which a Map's iteration allows.
276
+ for (const group of (yield* indexes.files).keys()) {
277
+ const entries = live.hashes.has(group) ? yield* readGroup("files", group, decodeEntry) : [];
278
+ const answers = Record.filter(
279
+ unionOf(entries.map((entry) => entry.answers)),
280
+ (_, fingerprint) => live.fingerprints.has(fingerprint),
281
+ );
282
+ deleted += yield* fold(
283
+ "files",
284
+ group,
285
+ Object.keys(answers).length === 0 ? undefined : { answers: sorted(answers) },
286
+ );
287
+ }
288
+ for (const key of (yield* indexes.tallies).keys()) {
289
+ const tallies = live.tallies.has(key) ? yield* readGroup("tallies", key, decodeTally) : [];
290
+ deleted += yield* fold(
291
+ "tallies",
292
+ key,
293
+ tallies.length === 0 ? undefined : relativeOf(tallyUnionOf(tallies)),
294
+ );
295
+ }
296
+ const old = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
297
+ for (const name of old) {
298
+ if (OLD_NAME.test(name)) deleted += yield* remove(path.join(directory, name));
299
+ }
300
+ return deleted;
301
+ });
302
+
303
+ return AuditCache.of({
304
+ get,
305
+ put: (contentHash, answers) => add("files", contentHash, { answers: sorted(answers) }),
306
+ tally,
307
+ putTally: (key, value) => add("tallies", key, relativeOf(value)),
308
+ prune,
60
309
  });
61
310
  }),
62
311
  );