@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.
- package/README.md +177 -37
- package/package.json +12 -12
- package/presets/alchemy/apis/match-remote-errors-by-tag.md +15 -0
- package/presets/alchemy/apis/schemas-at-trust-boundaries.md +18 -0
- package/presets/alchemy/data/commit-migrations-then-deploy.md +15 -0
- package/presets/alchemy/data/hyperdrive-direct-origin.md +14 -0
- package/presets/alchemy/data/migrations-use-direct-url.md +11 -0
- package/presets/alchemy/data/never-disable-tls-verification.md +16 -0
- package/presets/alchemy/data/parameterized-sql.md +13 -0
- package/presets/alchemy/data/private-endpoint-at-runtime.md +12 -0
- package/presets/alchemy/data/single-migration-owner.md +13 -0
- package/presets/alchemy/durable/idempotent-event-handlers.md +15 -0
- package/presets/alchemy/durable/io-inside-steps.md +17 -0
- package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +15 -0
- package/presets/alchemy/durable/stable-step-names.md +11 -0
- package/presets/alchemy/durable/webhook-state-is-truth.md +14 -0
- package/presets/alchemy/providers/diff-guards-unresolved-props.md +16 -0
- package/presets/alchemy/providers/idempotent-delete.md +14 -0
- package/presets/alchemy/providers/lazy-layer-construction.md +14 -0
- package/presets/alchemy/providers/reconcile-from-observed-state.md +20 -0
- package/presets/alchemy/resources/anchor-paths-with-import-meta.md +14 -0
- package/presets/alchemy/resources/idempotent-actions-and-seeds.md +19 -0
- package/presets/alchemy/resources/previews-reference-shared-resources.md +15 -0
- package/presets/alchemy/resources/protect-irreplaceable-data.md +12 -0
- package/presets/alchemy/resources/remote-state-for-ci.md +22 -0
- package/presets/alchemy/resources/stable-logical-ids.md +13 -0
- package/presets/alchemy/resources/stage-unique-physical-names.md +15 -0
- package/presets/alchemy/runtime/bind-tag-not-implementation.md +27 -0
- package/presets/alchemy/runtime/deterministic-config-defaults.md +13 -0
- package/presets/alchemy/runtime/disposables-in-request-scope.md +27 -0
- package/presets/alchemy/runtime/lambda-no-post-response-work.md +19 -0
- package/presets/alchemy/runtime/no-per-request-work-at-construction.md +24 -0
- package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +31 -0
- package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +13 -0
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +15 -0
- package/presets/alchemy/secrets/none-in-browser-env.md +15 -0
- package/presets/alchemy/secrets/redacted-until-use.md +19 -0
- package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +17 -0
- package/presets/alchemy/security/authorize-public-endpoints.md +20 -0
- package/presets/alchemy/security/github-secret-not-variable.md +12 -0
- package/presets/alchemy/security/identity-from-verified-session.md +14 -0
- package/presets/alchemy/security/keep-auth-protections-on.md +19 -0
- package/presets/alchemy/security/least-privilege-bindings.md +13 -0
- package/presets/alchemy/security/least-privilege-ci-credentials.md +24 -0
- package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +15 -0
- package/presets/effect/basics/external-calls-are-resilient.md +3 -3
- package/presets/effect/config/tests-provide-values-directly.md +3 -3
- package/presets/effect/config/validate-with-schema.md +13 -2
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/services/dependencies-through-layers.md +42 -0
- package/presets/effect/services/operations-have-no-requirements.md +46 -0
- package/presets/effect/services/test-layers-are-in-memory.md +3 -3
- package/presets/effect/testing/test-clock-for-time.md +1 -1
- package/skills/adhere/SKILL.md +3 -2
- package/src/cli.ts +42 -16
- package/src/config.ts +12 -2
- package/src/excerpt.ts +255 -0
- package/src/highlight.ts +82 -20
- package/src/init.ts +10 -4
- package/src/main.ts +35 -4
- package/src/mechanical.ts +89 -0
- package/src/presets.ts +45 -8
- package/src/rules.ts +2 -0
- package/src/services/AdhereConfig.ts +18 -20
- package/src/services/AuditCache.ts +272 -23
- package/src/services/Jev.http.ts +66 -12
- package/src/services/Jev.ts +66 -6
- package/src/services/SourceWalker.ts +11 -4
- package/src/wording.ts +90 -0
- package/src/workflows/audit.ts +169 -26
- package/src/workflows/format.ts +35 -18
- package/presets/effect/basics/fn-for-named-effects.md +0 -19
- package/presets/effect/cli/handlers-delegate.md +0 -24
- package/presets/effect/data/decode-json-with-schema.md +0 -18
- package/presets/effect/data/records-are-schema-classes.md +0 -23
- package/presets/effect/errors/domain-errors-are-tagged.md +0 -19
- package/presets/effect/platform/os-access-through-platform-services.md +0 -26
- package/presets/effect/services/methods-have-no-requirements.md +0 -20
- package/presets/effect/services/provide-at-entry.md +0 -23
- 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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
export
|
|
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,
|
|
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
|
-
|
|
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
|
|
48
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
);
|