@drkmttr/adhere 0.18.0 → 0.21.2
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 +1099 -1066
- package/package.json +15 -15
- package/presets/README.md +132 -0
- package/skills/adhere/SKILL.md +51 -33
- package/skills/adhere-setup/SKILL.md +77 -53
- package/src/cli.ts +133 -14
- package/src/config.ts +157 -138
- package/src/create/agents.ts +149 -0
- package/src/create/ask.ts +147 -0
- package/src/create/look.ts +140 -0
- package/src/create/wizard.ts +432 -0
- package/src/index.ts +2 -0
- package/src/init.ts +24 -4
- package/src/install.ts +30 -24
- package/src/instructions.ts +76 -0
- package/src/markdown.ts +3 -9
- package/src/rules.ts +42 -45
- package/src/services/AdhereConfig.ts +31 -18
- package/src/services/Credentials.ts +14 -8
- package/src/services/Jev.reads.test.ts +26 -2
- package/src/services/Jev.ts +36 -14
- package/src/wording.ts +1 -1
- package/src/workflows/audit.ts +84 -16
- package/src/workflows/format.ts +97 -0
- package/src/workspaces.test.ts +37 -1
- package/src/workspaces.ts +121 -12
package/src/rules.ts
CHANGED
|
@@ -60,11 +60,9 @@ interface RuleFile {
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* The rules among `leaves`, the paths in `directory`, whose rules sit under
|
|
63
|
-
* `prefix` one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* every rule's directory, but a README.md: before rule directories, it was a
|
|
67
|
-
* rule, and the refusal says where it goes now to keep its id.
|
|
63
|
+
* `prefix` one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`. Any other
|
|
64
|
+
* file, such as a helper a RULE.ts imports, is not a rule. A directory with
|
|
65
|
+
* both files refuses.
|
|
68
66
|
*/
|
|
69
67
|
const ruleFilesIn = (
|
|
70
68
|
directory: string,
|
|
@@ -85,29 +83,6 @@ const ruleFilesIn = (
|
|
|
85
83
|
}),
|
|
86
84
|
);
|
|
87
85
|
}
|
|
88
|
-
const homes = files.map(({ leaf }) => leaf.slice(0, leaf.lastIndexOf("/") + 1));
|
|
89
|
-
const flat = leaves.filter((leaf) => {
|
|
90
|
-
const name = segmentsOf(leaf).at(-1) ?? "";
|
|
91
|
-
return (
|
|
92
|
-
leaf.endsWith(".md") &&
|
|
93
|
-
!RULE_FILES.has(name) &&
|
|
94
|
-
name !== "README.md" &&
|
|
95
|
-
!homes.some((home) => leaf.startsWith(home))
|
|
96
|
-
);
|
|
97
|
-
});
|
|
98
|
-
if (flat.length > 0) {
|
|
99
|
-
return Effect.fail(
|
|
100
|
-
ConfigUnavailable.make({
|
|
101
|
-
message: [
|
|
102
|
-
`Rules now live one to a directory, as ${directory}/${prefix}<id>/RULE.md or RULE.ts. Move each of these to keep its id:`,
|
|
103
|
-
...flat.map(
|
|
104
|
-
(leaf) =>
|
|
105
|
-
` ${directory}/${leaf} → ${directory}/${prefix}${leaf.slice(0, -".md".length)}/RULE.md`,
|
|
106
|
-
),
|
|
107
|
-
].join("\n"),
|
|
108
|
-
}),
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
86
|
return Effect.succeed(files);
|
|
112
87
|
};
|
|
113
88
|
|
|
@@ -161,7 +136,7 @@ const moreSpecific = (a: RuleEntry, b: RuleEntry): RuleEntry =>
|
|
|
161
136
|
* config's inline rule is, so a hook such as `appendState` comes with it.
|
|
162
137
|
* Like a config, the file is imported, so its code runs.
|
|
163
138
|
*/
|
|
164
|
-
const importRule = Effect.fn("importRule")(function* (file: string) {
|
|
139
|
+
export const importRule = Effect.fn("importRule")(function* (file: string) {
|
|
165
140
|
const path = yield* Path.Path;
|
|
166
141
|
const url = yield* path.toFileUrl(file).pipe(Effect.orDie);
|
|
167
142
|
const loaded = yield* Effect.tryPromise({
|
|
@@ -255,25 +230,47 @@ export const globalRuleSet = (rules: Readonly<Record<RuleId, Rule>>, root: strin
|
|
|
255
230
|
/**
|
|
256
231
|
* The rules as a config's overrides leave them, each found by the id a report
|
|
257
232
|
* names it with: a rule set `off` gone, and any other with the level and
|
|
258
|
-
* threshold its override gives, over its own
|
|
233
|
+
* threshold its override gives, over its own, and its `options` decoded by
|
|
234
|
+
* the rule's schema, `{}` when unset. Options a rule does not take, or that
|
|
235
|
+
* do not decode, refuse the run.
|
|
259
236
|
*/
|
|
260
|
-
export const withOverrides = (
|
|
237
|
+
export const withOverrides = Effect.fn("withOverrides")(function* (
|
|
261
238
|
entries: RuleSet,
|
|
262
239
|
overrides: Readonly<Record<string, Override>>,
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
240
|
+
) {
|
|
241
|
+
const kept = yield* Effect.forEach(entries, (entry) =>
|
|
242
|
+
Effect.gen(function* () {
|
|
243
|
+
const id = shownId(entry);
|
|
244
|
+
const override = overrides[id];
|
|
245
|
+
const { level, threshold, options }: Exclude<Override, string> =
|
|
246
|
+
typeof override === "string" ? { level: override } : (override ?? {});
|
|
247
|
+
if (level === "off") return [];
|
|
248
|
+
if (entry.rule.options === undefined && options !== undefined) {
|
|
249
|
+
return yield* ConfigUnavailable.make({
|
|
250
|
+
message: `overrides sets options for ${id}, which takes none.`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const optionValues =
|
|
254
|
+
entry.rule.options === undefined
|
|
255
|
+
? undefined
|
|
256
|
+
: yield* Schema.decodeUnknownEffect(entry.rule.options)(options ?? {}).pipe(
|
|
257
|
+
Effect.mapError((problem) =>
|
|
258
|
+
ConfigUnavailable.make({
|
|
259
|
+
message: `The options of ${id}, set in overrides, did not decode: ${problem.message}`,
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
const rule: Rule = {
|
|
264
|
+
...entry.rule,
|
|
265
|
+
...(level === undefined ? {} : { level }),
|
|
266
|
+
...(threshold === undefined ? {} : { threshold }),
|
|
267
|
+
...(optionValues === undefined ? {} : { optionValues }),
|
|
268
|
+
};
|
|
269
|
+
return [{ ...entry, rule }];
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
return kept.flat();
|
|
273
|
+
});
|
|
277
274
|
|
|
278
275
|
/**
|
|
279
276
|
* Whether a rule judges a file: a test file only when the rule says `tests`,
|
|
@@ -21,14 +21,14 @@ import {
|
|
|
21
21
|
shownId,
|
|
22
22
|
withOverrides,
|
|
23
23
|
} from "#rules.ts";
|
|
24
|
-
import { workspacePackagesOf } from "#workspaces.ts";
|
|
24
|
+
import { workspaceDependentsOf, workspacePackagesOf } from "#workspaces.ts";
|
|
25
25
|
import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
|
|
26
26
|
|
|
27
27
|
export class AdhereConfig extends Context.Service<AdhereConfig, ResolvedConfig>()(
|
|
28
28
|
"@drkmttr/adhere/services/AdhereConfig",
|
|
29
29
|
) {}
|
|
30
30
|
|
|
31
|
-
const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
|
|
31
|
+
export const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
|
|
32
32
|
const path = yield* Path.Path;
|
|
33
33
|
const url = yield* path.toFileUrl(file).pipe(Effect.orDie);
|
|
34
34
|
const loaded = yield* Effect.tryPromise({
|
|
@@ -53,6 +53,23 @@ const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetN
|
|
|
53
53
|
return [name, { ...preset, rules } satisfies Loaded<Preset>] as const;
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
/** Locate the optional config without loading rules or presets. */
|
|
57
|
+
export const findConfigFile = Effect.fn("AdhereConfig.find")(function* (cwd: string) {
|
|
58
|
+
const path = yield* Path.Path;
|
|
59
|
+
const fs = yield* FileSystem.FileSystem;
|
|
60
|
+
const found = yield* Effect.filter(CONFIG_FILES, (file) =>
|
|
61
|
+
fs
|
|
62
|
+
.exists(path.join(cwd, file))
|
|
63
|
+
.pipe(Effect.mapError((problem) => ConfigUnavailable.make({ message: problem.message }))),
|
|
64
|
+
);
|
|
65
|
+
if (found.length > 1) {
|
|
66
|
+
return yield* ConfigUnavailable.make({
|
|
67
|
+
message: `More than one config file: ${found.join(", ")}. Keep one.`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return found[0];
|
|
71
|
+
});
|
|
72
|
+
|
|
56
73
|
/** Every rule of every built-in preset, by the id a report names it with. */
|
|
57
74
|
const everyPresetRule = Effect.fn("AdhereConfig.everyPresetRule")(function* (base: string) {
|
|
58
75
|
const loaded = yield* Effect.forEach(Object.entries(presets), ([name, preset]) =>
|
|
@@ -73,19 +90,8 @@ export const AdhereConfigLive = (flags: Flags) =>
|
|
|
73
90
|
Layer.effect(AdhereConfig)(
|
|
74
91
|
Effect.gen(function* () {
|
|
75
92
|
const path = yield* Path.Path;
|
|
76
|
-
const fs = yield* FileSystem.FileSystem;
|
|
77
93
|
const cwd = path.resolve();
|
|
78
|
-
const
|
|
79
|
-
fs
|
|
80
|
-
.exists(path.join(cwd, target))
|
|
81
|
-
.pipe(Effect.mapError((problem) => ConfigUnavailable.make({ message: problem.message })));
|
|
82
|
-
const found = yield* Effect.filter(CONFIG_FILES, present);
|
|
83
|
-
if (found.length > 1) {
|
|
84
|
-
return yield* ConfigUnavailable.make({
|
|
85
|
-
message: `More than one config file: ${found.join(", ")}. Keep one.`,
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
const configFile = found[0];
|
|
94
|
+
const configFile = yield* findConfigFile(cwd);
|
|
89
95
|
const hasPreset = flags.presets !== undefined && flags.presets.length > 0;
|
|
90
96
|
const config: typeof AdhereConfigSchema.Type =
|
|
91
97
|
configFile === undefined
|
|
@@ -130,15 +136,22 @@ export const AdhereConfigLive = (flags: Flags) =>
|
|
|
130
136
|
});
|
|
131
137
|
}
|
|
132
138
|
}
|
|
133
|
-
const scopedRules = withOverrides(entries, overrides);
|
|
139
|
+
const scopedRules = yield* withOverrides(entries, overrides);
|
|
134
140
|
// Read on every run so workspace edits are seen, but only when the config enables this context.
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
141
|
+
const dependents = resolved.reads.includes("dependents");
|
|
142
|
+
const workspacePackages =
|
|
143
|
+
dependents || resolved.reads.includes("workspacePackages")
|
|
144
|
+
? yield* workspacePackagesOf(cwd)
|
|
145
|
+
: undefined;
|
|
146
|
+
const workspaceDependents =
|
|
147
|
+
dependents && workspacePackages !== undefined
|
|
148
|
+
? yield* workspaceDependentsOf(cwd, workspacePackages)
|
|
149
|
+
: undefined;
|
|
138
150
|
return AdhereConfig.of({
|
|
139
151
|
...resolved,
|
|
140
152
|
scopedRules,
|
|
141
153
|
...(workspacePackages === undefined ? {} : { workspacePackages }),
|
|
154
|
+
...(workspaceDependents === undefined ? {} : { workspaceDependents }),
|
|
142
155
|
});
|
|
143
156
|
}),
|
|
144
157
|
);
|
|
@@ -66,6 +66,18 @@ export class Credentials extends Context.Service<
|
|
|
66
66
|
|
|
67
67
|
const refused = (message: string) => CredentialsUnavailable.make({ message });
|
|
68
68
|
|
|
69
|
+
/** `$XDG_CONFIG_HOME/adhere`, or `~/.config/adhere` without it, on every platform: where adhere keeps a user's own files. */
|
|
70
|
+
export const adhereConfigDirectory = (path: Path.Path) =>
|
|
71
|
+
Config.NonEmptyString("XDG_CONFIG_HOME").pipe(
|
|
72
|
+
Config.orElse(() =>
|
|
73
|
+
Config.NonEmptyString("HOME").pipe(
|
|
74
|
+
Config.orElse(() => Config.NonEmptyString("USERPROFILE")),
|
|
75
|
+
Config.map((home) => path.join(home, ".config")),
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
Config.map((configHome) => path.join(configHome, "adhere")),
|
|
79
|
+
);
|
|
80
|
+
|
|
69
81
|
/**
|
|
70
82
|
* The keys live in `$XDG_CONFIG_HOME/adhere/credentials.json`, or under
|
|
71
83
|
* `~/.config` without it, on every platform. The file is written whole to a
|
|
@@ -77,14 +89,8 @@ export const CredentialsLive = Layer.effect(Credentials)(
|
|
|
77
89
|
const fs = yield* FileSystem.FileSystem;
|
|
78
90
|
const path = yield* Path.Path;
|
|
79
91
|
|
|
80
|
-
const file =
|
|
81
|
-
Config.
|
|
82
|
-
Config.NonEmptyString("HOME").pipe(
|
|
83
|
-
Config.orElse(() => Config.NonEmptyString("USERPROFILE")),
|
|
84
|
-
Config.map((home) => path.join(home, ".config")),
|
|
85
|
-
),
|
|
86
|
-
),
|
|
87
|
-
Config.map((configHome) => path.join(configHome, "adhere", "credentials.json")),
|
|
92
|
+
const file = adhereConfigDirectory(path).pipe(
|
|
93
|
+
Config.map((directory) => path.join(directory, "credentials.json")),
|
|
88
94
|
Effect.mapError(() =>
|
|
89
95
|
refused("No home directory to keep the API key in: set HOME or XDG_CONFIG_HOME."),
|
|
90
96
|
),
|
|
@@ -76,6 +76,7 @@ const recordedJev = (
|
|
|
76
76
|
Layer.provide([
|
|
77
77
|
Layer.succeed(FetchHttpClient.Fetch, fetch),
|
|
78
78
|
Layer.succeed(AdhereConfig, {
|
|
79
|
+
typeAware: false,
|
|
79
80
|
model: "jev-latest",
|
|
80
81
|
threshold: 0.8,
|
|
81
82
|
sufficiencyThreshold: 0.6,
|
|
@@ -193,6 +194,28 @@ describe("prepared shared Jev reads", () => {
|
|
|
193
194
|
expect(locateRequests(lines, rules, readState)).toBe(3);
|
|
194
195
|
});
|
|
195
196
|
|
|
197
|
+
it("sends a rule's options in requests of its own, under `options`", () => {
|
|
198
|
+
const optioned = { ...plain, optionValues: { edgePaths: ["src/edge"] } };
|
|
199
|
+
const rules = { plain, optioned, alsoPlain: plain };
|
|
200
|
+
const readState: ReadState = { workspacePackages: { orders: "packages/orders" } };
|
|
201
|
+
const groups = requestGroups(rules);
|
|
202
|
+
expect(groups.map((group) => Object.keys(group))).toEqual([
|
|
203
|
+
["plain", "alsoPlain"],
|
|
204
|
+
["optioned"],
|
|
205
|
+
]);
|
|
206
|
+
const [shared, own] = groups.map((group) => locateBody("", lines, group, readState).state);
|
|
207
|
+
expect(shared?.options).toBeUndefined();
|
|
208
|
+
expect(own?.options).toEqual({
|
|
209
|
+
edgePaths: ["src/edge"],
|
|
210
|
+
__comment: expect.stringMatching(/options\./),
|
|
211
|
+
});
|
|
212
|
+
expect(judgeBody("", lines, { optioned }, [], readState).state).toEqual(own);
|
|
213
|
+
expect(own?.workspacePackages).toEqual(shared?.workspacePackages);
|
|
214
|
+
const huge = { ...plain, optionValues: { edgePaths: ["p".repeat(120_000)] } };
|
|
215
|
+
expect(fits(lines, { plain, optioned }, readState)).toBe(true);
|
|
216
|
+
expect(fits(lines, { plain, huge }, readState)).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
196
219
|
it("preserves caller comments and raw context through judge, locate, and appended maps", async () => {
|
|
197
220
|
const readState = {
|
|
198
221
|
__comment: "User context: only target code is audited.",
|
|
@@ -493,9 +516,10 @@ describe("prepared shared Jev reads", () => {
|
|
|
493
516
|
expect(readState.code).toEqual({});
|
|
494
517
|
});
|
|
495
518
|
|
|
496
|
-
it("
|
|
519
|
+
it("uses only caller-prepared context in HTTP with workspace-only defaults", async () => {
|
|
497
520
|
const config = resolveConfig({ presets: [], rules: { plain } });
|
|
498
|
-
expect(config.reads).toEqual(["workspacePackages"
|
|
521
|
+
expect(config.reads).toEqual(["workspacePackages"]);
|
|
522
|
+
expect(config.typeAware).toBe(false);
|
|
499
523
|
const { sent, layer } = recordedJev({ unused: "packages/unused" }, config.reads);
|
|
500
524
|
await Effect.runPromise(
|
|
501
525
|
Effect.gen(function* () {
|
package/src/services/Jev.ts
CHANGED
|
@@ -141,11 +141,15 @@ export const tokensOf = (value: unknown): number => Math.ceil(bytesOf(value) / 3
|
|
|
141
141
|
|
|
142
142
|
const READ_COMMENTS: Readonly<Record<string, string>> = {
|
|
143
143
|
symbols:
|
|
144
|
-
"Compiler-
|
|
144
|
+
"Compiler-rendered types and documentation for symbols used in `code`; keys are symbol names. A declared type, assertion, or type predicate is only a compile-time claim, not proof of runtime validation. When `references` show an implementation, inspect its actual checks. A predicate that returns true, tests only truthiness, or checks only whether a value is an object does not validate the fields it claims.",
|
|
145
145
|
references:
|
|
146
146
|
"Declarations of symbols used in `code`. Each key is `symbol:pathname`: the symbol name as used in `code` followed by its repository-relative declaration path. Each value is a complete declaration excerpt.",
|
|
147
147
|
workspacePackages:
|
|
148
148
|
"Workspace packages imported by `code`; keys are package names and values are repository-relative package directories. Only packages imported by this file are listed.",
|
|
149
|
+
dependents:
|
|
150
|
+
"What depends on the file `code` holds. `packages` maps workspace packages whose package.json depends on this file's package to their repository-relative directories. `files` lists repository-relative paths of files that import this file by a relative path; `omittedFiles`, when present, counts more that are not listed. Package-name imports of this file are not in `files`.",
|
|
151
|
+
options:
|
|
152
|
+
"Settings this project gives the rule the questions ask about, which the rule names as `options.<key>`.",
|
|
149
153
|
};
|
|
150
154
|
|
|
151
155
|
/** Explain the known context maps while preserving caller-provided descriptions and target code. */
|
|
@@ -347,11 +351,21 @@ export const matcherQuestions = (id: RuleId, rule: Rule) =>
|
|
|
347
351
|
[matcherKey(kind, id, index), matcherQuestion(rule, kind, matcher)] as const,
|
|
348
352
|
),
|
|
349
353
|
);
|
|
350
|
-
|
|
351
|
-
/** The room for one question beside the file and its shared prepared context. Hooks are not predicted. */
|
|
354
|
+
/** The room for one question beside the file and the prepared context of its request. Hooks are not predicted. */
|
|
352
355
|
export const questionRoom = (lines: Lines, readState: ReadState = {}): number =>
|
|
353
356
|
QUESTION_CONTEXT - WRAPPER - tokensOf(stateOf(lines, readState));
|
|
354
357
|
|
|
358
|
+
/**
|
|
359
|
+
* The shared context, with the options of the rule in `rules` that has them
|
|
360
|
+
* under `options`: `requestGroups` gives such a rule a body of its own.
|
|
361
|
+
*/
|
|
362
|
+
const withOptions = (rules: Rules, readState: ReadState): ReadState => {
|
|
363
|
+
const options = Object.values(rules).find(
|
|
364
|
+
(rule) => rule.optionValues !== undefined,
|
|
365
|
+
)?.optionValues;
|
|
366
|
+
return options === undefined ? readState : { ...readState, options };
|
|
367
|
+
};
|
|
368
|
+
|
|
355
369
|
/**
|
|
356
370
|
* One question per rule, one per matcher of each rule, and the linter
|
|
357
371
|
* question beside each rule in `sampled`.
|
|
@@ -364,7 +378,7 @@ export const judgeBody = (
|
|
|
364
378
|
readState: ReadState = {},
|
|
365
379
|
) => ({
|
|
366
380
|
model,
|
|
367
|
-
state: stateOf(lines, readState),
|
|
381
|
+
state: stateOf(lines, withOptions(rules, readState)),
|
|
368
382
|
questions: {
|
|
369
383
|
...Record.map(rules, (rule): ReturnType<typeof judgeQuestion> => judgeQuestion(rule)),
|
|
370
384
|
...Object.fromEntries(
|
|
@@ -482,7 +496,7 @@ export const locateBody = (
|
|
|
482
496
|
readState: ReadState = {},
|
|
483
497
|
) => {
|
|
484
498
|
const sections = sectionsOf(lines);
|
|
485
|
-
const state = stateOf(lines, readState, sections);
|
|
499
|
+
const state = stateOf(lines, withOptions(rules, readState), sections);
|
|
486
500
|
const criteria = Record.map(state.code, () => null);
|
|
487
501
|
const choosing = sections.length > 1;
|
|
488
502
|
const withCode = sections.map((section) => lineCriteria(lines, section));
|
|
@@ -513,10 +527,16 @@ export const locateBody = (
|
|
|
513
527
|
};
|
|
514
528
|
|
|
515
529
|
/**
|
|
516
|
-
* Whether Jev can take every request a file may need:
|
|
517
|
-
* one choice, and
|
|
530
|
+
* Whether Jev can take every request a file may need: each request group's
|
|
531
|
+
* sections within one choice, and its state, options and all, beside its
|
|
532
|
+
* longest question. Hooks are not predicted.
|
|
518
533
|
*/
|
|
519
|
-
export const fits = (lines: Lines, rules: Rules, readState: ReadState = {}): boolean =>
|
|
534
|
+
export const fits = (lines: Lines, rules: Rules, readState: ReadState = {}): boolean =>
|
|
535
|
+
Record.isEmptyRecord(rules)
|
|
536
|
+
? groupFits(lines, rules, readState)
|
|
537
|
+
: requestGroups(rules).every((group) => groupFits(lines, group, withOptions(group, readState)));
|
|
538
|
+
|
|
539
|
+
const groupFits = (lines: Lines, rules: Rules, readState: ReadState): boolean => {
|
|
520
540
|
const sections = sectionsOf(lines);
|
|
521
541
|
if (sections.length > CHOICE_LIMIT) return false;
|
|
522
542
|
const state = stateOf(lines, readState, sections);
|
|
@@ -633,16 +653,18 @@ export const requestsOf = <Q>(body: Body<Q>): ReadonlyArray<Body<Q>> => {
|
|
|
633
653
|
};
|
|
634
654
|
|
|
635
655
|
/**
|
|
636
|
-
* All rules share one body except those with `appendState
|
|
637
|
-
* of its own so only its questions read what
|
|
656
|
+
* All rules share one body except those with `appendState` or options, each
|
|
657
|
+
* in a body of its own so only its questions read what it adds to the shared state.
|
|
638
658
|
*/
|
|
639
659
|
export const requestGroups = (rules: Rules): ReadonlyArray<Rules> => {
|
|
640
|
-
const
|
|
660
|
+
const alone = Record.filter(
|
|
661
|
+
rules,
|
|
662
|
+
(rule) => rule.appendState !== undefined || rule.optionValues !== undefined,
|
|
663
|
+
);
|
|
664
|
+
const shared = Record.filter(rules, (_, id) => !(id in alone));
|
|
641
665
|
return [
|
|
642
666
|
...(Record.isEmptyRecord(shared) ? [] : [shared]),
|
|
643
|
-
...Object.entries(
|
|
644
|
-
rule.appendState === undefined ? [] : [{ [id]: rule }],
|
|
645
|
-
),
|
|
667
|
+
...Object.entries(alone).map(([id, rule]) => ({ [id]: rule })),
|
|
646
668
|
];
|
|
647
669
|
};
|
|
648
670
|
|
package/src/wording.ts
CHANGED
|
@@ -60,7 +60,7 @@ export const straysAmong = (entries: ReadonlyArray<RuleEntry>): ReadonlyArray<St
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
/** One way a rule strays, as a sentence. */
|
|
63
|
-
const sentenceOf = (straying: Straying): string => {
|
|
63
|
+
export const sentenceOf = (straying: Straying): string => {
|
|
64
64
|
switch (straying._tag) {
|
|
65
65
|
case "Unsaid":
|
|
66
66
|
return `The description does not say "${straying.word}", though the rule has a ${straying.word} example.`;
|
package/src/workflows/audit.ts
CHANGED
|
@@ -40,7 +40,13 @@ import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
|
|
|
40
40
|
import { priceOf } from "#pricing.ts";
|
|
41
41
|
import { applicableRules, judgesFile, shownId } from "#rules.ts";
|
|
42
42
|
import { suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
dependentFilesIn,
|
|
45
|
+
importSpecifiersIn,
|
|
46
|
+
packageOf,
|
|
47
|
+
workspacePackagesIn,
|
|
48
|
+
} from "#workspaces.ts";
|
|
49
|
+
import { relative, sep } from "node:path";
|
|
44
50
|
import { type Crypto, Duration, Effect, Option, Record, Result, Semaphore, Stream } from "effect";
|
|
45
51
|
|
|
46
52
|
export interface Finding {
|
|
@@ -125,19 +131,24 @@ const LAYOUT = "sections";
|
|
|
125
131
|
|
|
126
132
|
/**
|
|
127
133
|
* What a judgment depends on besides the file: the model, how the file is
|
|
128
|
-
* laid out,
|
|
129
|
-
* judge question, and its matchers' when it
|
|
130
|
-
* matcher re-judges the rule, and a question asked
|
|
131
|
-
* every rule. So does an edited
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
+
* laid out, what the config has Jev read, and the questions asked for the
|
|
135
|
+
* rule, which carry the rule: its judge question, and its matchers' when it
|
|
136
|
+
* has any. An edited rule or matcher re-judges the rule, and a question asked
|
|
137
|
+
* differently, or other reads, re-judge every rule. So does an edited
|
|
138
|
+
* `appendState`, by its source; what the hook reads outside the file is not
|
|
139
|
+
* known until it runs, so a change there does not. Options set differently
|
|
140
|
+
* re-judge their rule: decoded, a struct's keys come in its schema's order,
|
|
141
|
+
* however the config writes them. What the reads find, such as related
|
|
142
|
+
* files, does not invalidate judgments.
|
|
134
143
|
*/
|
|
135
|
-
const fingerprintOf = (model: string, rule: Rule) => {
|
|
144
|
+
const fingerprintOf = (model: string, reads: string, rule: Rule) => {
|
|
136
145
|
const matchers = matcherQuestions("", rule).map(([, question]) => question);
|
|
137
146
|
const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
|
|
138
147
|
const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
|
|
148
|
+
const options =
|
|
149
|
+
rule.optionValues === undefined ? "" : `\u0000${JSON.stringify(rule.optionValues)}`;
|
|
139
150
|
return sha256(
|
|
140
|
-
`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
|
|
151
|
+
`${model}\u0000${LAYOUT}${reads}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${options}`,
|
|
141
152
|
);
|
|
142
153
|
};
|
|
143
154
|
|
|
@@ -368,6 +379,9 @@ const hasLocatedFindings = (plan: FilePlan): boolean =>
|
|
|
368
379
|
);
|
|
369
380
|
});
|
|
370
381
|
|
|
382
|
+
/** At most this many of a file's dependent files are listed; the rest are counted. */
|
|
383
|
+
const MAX_DEPENDENT_FILES = 50;
|
|
384
|
+
|
|
371
385
|
export interface PlanOptions {
|
|
372
386
|
/** Select at most this many files needing judge or cached-locate requests; zero plans only. */
|
|
373
387
|
readonly limit?: number | undefined;
|
|
@@ -386,15 +400,49 @@ export const planAudit = (
|
|
|
386
400
|
const workspacePackages = config.reads.includes("workspacePackages")
|
|
387
401
|
? (config.workspacePackages ?? {})
|
|
388
402
|
: undefined;
|
|
403
|
+
const catalog = config.workspacePackages ?? {};
|
|
389
404
|
const nativeReads: SourceReadRequest = {
|
|
390
|
-
symbols: config.
|
|
391
|
-
references: config.
|
|
405
|
+
symbols: config.typeAware,
|
|
406
|
+
references: config.typeAware,
|
|
392
407
|
includeComments: config.includeComments === true,
|
|
393
408
|
maxSymbolLength: config.maxSymbolLength,
|
|
394
409
|
maxSymbolDocumentationLength: config.maxSymbolDocumentationLength,
|
|
395
410
|
};
|
|
396
411
|
const walker = yield* SourceWalker;
|
|
397
412
|
const files = yield* walker.files;
|
|
413
|
+
// Each file's path from the working directory, `/`-separated, as Jev reads it.
|
|
414
|
+
const fromRoot = new Map(
|
|
415
|
+
files.map((file) => [file.path, relative(process.cwd(), file.path).split(sep).join("/")]),
|
|
416
|
+
);
|
|
417
|
+
// Only the specifiers stay in memory: each file is read again when it is planned.
|
|
418
|
+
const dependentFiles = config.reads.includes("dependents")
|
|
419
|
+
? dependentFilesIn(
|
|
420
|
+
new Map(
|
|
421
|
+
yield* Effect.forEach(
|
|
422
|
+
files,
|
|
423
|
+
(file) =>
|
|
424
|
+
Effect.map(
|
|
425
|
+
walker.read(file),
|
|
426
|
+
({ lines }) =>
|
|
427
|
+
[fromRoot.get(file.path)!, importSpecifiersIn(lines.join("\n"))] as const,
|
|
428
|
+
),
|
|
429
|
+
{ concurrency: 8 },
|
|
430
|
+
),
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
: undefined;
|
|
434
|
+
const dependentsOf = (file: SourceFile) => {
|
|
435
|
+
const at = fromRoot.get(file.path)!;
|
|
436
|
+
const name = packageOf(at, catalog);
|
|
437
|
+
const importers = dependentFiles?.get(at) ?? [];
|
|
438
|
+
return {
|
|
439
|
+
packages: (name === undefined ? undefined : config.workspaceDependents?.[name]) ?? {},
|
|
440
|
+
files: importers.slice(0, MAX_DEPENDENT_FILES),
|
|
441
|
+
...(importers.length > MAX_DEPENDENT_FILES
|
|
442
|
+
? { omittedFiles: importers.length - MAX_DEPENDENT_FILES }
|
|
443
|
+
: {}),
|
|
444
|
+
};
|
|
445
|
+
};
|
|
398
446
|
|
|
399
447
|
// Each preset's rule, with the preset it came from, for its findings to name.
|
|
400
448
|
const presetOfRule = new Map(
|
|
@@ -416,11 +464,23 @@ export const planAudit = (
|
|
|
416
464
|
);
|
|
417
465
|
|
|
418
466
|
// Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
|
|
467
|
+
// The reads that take effect key every judgment; code-only keeps the key it always had.
|
|
468
|
+
// includeComments shapes other files' `references` excerpts, which the target's hash misses.
|
|
469
|
+
const reads = [
|
|
470
|
+
...(workspacePackages === undefined ? [] : ["workspacePackages"]),
|
|
471
|
+
...(dependentFiles === undefined ? [] : ["dependents"]),
|
|
472
|
+
...(config.typeAware
|
|
473
|
+
? [
|
|
474
|
+
`symbols:${config.maxSymbolLength}:${config.maxSymbolDocumentationLength}:${config.includeComments === true}`,
|
|
475
|
+
]
|
|
476
|
+
: []),
|
|
477
|
+
];
|
|
478
|
+
const readsKey = reads.length === 0 ? "" : `\u0000${reads.join(",")}`;
|
|
419
479
|
const preparedRules = new Map<Rule, PreparedRule>(
|
|
420
480
|
yield* Effect.forEach(
|
|
421
481
|
new Set(config.scopedRules?.map(({ rule }) => rule) ?? Object.values(config.rules)),
|
|
422
482
|
(rule) =>
|
|
423
|
-
Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
|
|
483
|
+
Effect.map(fingerprintOf(config.model, readsKey, rule), (fingerprint) => {
|
|
424
484
|
const preset = presetOfRule.get(rule);
|
|
425
485
|
return [
|
|
426
486
|
rule,
|
|
@@ -440,10 +500,12 @@ export const planAudit = (
|
|
|
440
500
|
const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
|
|
441
501
|
const rules = configuredRules(file, suppressions);
|
|
442
502
|
const code = lines.join("\n");
|
|
443
|
-
const readState: ReadState =
|
|
444
|
-
workspacePackages === undefined
|
|
503
|
+
const readState: ReadState = {
|
|
504
|
+
...(workspacePackages === undefined
|
|
445
505
|
? {}
|
|
446
|
-
: { workspacePackages: workspacePackagesIn(code, workspacePackages) }
|
|
506
|
+
: { workspacePackages: workspacePackagesIn(code, workspacePackages) }),
|
|
507
|
+
...(dependentFiles === undefined ? {} : { dependents: dependentsOf(file) }),
|
|
508
|
+
};
|
|
447
509
|
// Hashed even when unjudged or skipped, so a prune keeps what other runs know of its content.
|
|
448
510
|
const hash = yield* sha256(code);
|
|
449
511
|
const prepared = Record.map(rules, (rule) => preparedRules.get(rule)!);
|
|
@@ -802,7 +864,13 @@ export const executeAudit = (
|
|
|
802
864
|
const room = questionRoom(file.lines, readState);
|
|
803
865
|
const sentSamples = Record.filter(sampled, (_, id) => {
|
|
804
866
|
const k = pending[id];
|
|
805
|
-
|
|
867
|
+
if (k === undefined) return false;
|
|
868
|
+
// A rule with options asks beside them, in a request of its own.
|
|
869
|
+
const left =
|
|
870
|
+
k.rule.optionValues === undefined
|
|
871
|
+
? room
|
|
872
|
+
: questionRoom(file.lines, { ...readState, options: k.rule.optionValues });
|
|
873
|
+
return tokensOf(linterQuestion(k.rule)) <= left;
|
|
806
874
|
});
|
|
807
875
|
const sampleIds = Object.keys(sentSamples);
|
|
808
876
|
const { probabilities, linter, matchers }: Judged = isEmpty(pending)
|