@drkmttr/adhere 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +226 -41
- package/package.json +7 -7
- package/presets/effect/basics/gen-for-sequencing.md +1 -1
- package/presets/effect/data/brand-meaningful-primitives.md +1 -1
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/services/dependencies-through-layers.md +1 -1
- package/presets/react/effects/clean-up-what-they-start.md +23 -0
- package/presets/react/effects/event-logic-in-handlers.md +29 -0
- package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
- package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
- package/presets/react/effects/notify-parents-in-handlers.md +29 -0
- package/presets/react/server/no-private-data-to-client.md +23 -0
- package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
- package/presets/react/state/no-contradictory-state.md +17 -0
- package/presets/react/state/no-props-copied-into-state.md +23 -0
- package/presets/react/state/store-ids-not-copies.md +20 -0
- package/presets/security/constant-time-secret-compare.md +22 -0
- package/presets/security/no-secrets-in-output.md +19 -0
- package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
- package/presets/security/parameterized-queries.md +16 -0
- package/presets/typescript/async/concurrent-independent-work.md +23 -0
- package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
- package/presets/typescript/design/no-argument-mutation.md +20 -0
- package/presets/typescript/errors/no-swallowed-errors.md +29 -0
- package/presets/typescript/errors/release-on-every-path.md +29 -0
- package/presets/typescript/testing/assert-outcomes.md +22 -0
- package/presets/typescript/testing/independent-tests.md +41 -0
- package/presets/typescript/types/assertions-only-when-proven.md +20 -0
- package/presets/typescript/types/derive-dont-restate.md +24 -0
- package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
- package/presets/typescript/types/validate-external-data.md +21 -0
- package/skills/adhere/SKILL.md +4 -3
- package/skills/adhere-fix/SKILL.md +10 -4
- package/src/cli.ts +85 -4
- package/src/config.ts +29 -1
- package/src/excerpt.ts +72 -62
- package/src/highlight.ts +2 -0
- package/src/init.ts +168 -13
- package/src/install.ts +147 -0
- package/src/markdown.ts +6 -0
- package/src/presets.ts +6 -0
- package/src/rules.ts +1 -1
- package/src/services/AuditCache.ts +31 -5
- package/src/services/Jev.http.ts +76 -11
- package/src/services/Jev.ts +191 -73
- package/src/services/SourceWalker.ts +23 -7
- package/src/suppress.ts +13 -7
- package/src/version.ts +25 -1
- package/src/workflows/audit.ts +96 -26
- package/src/workflows/format.ts +51 -11
- package/presets/alchemy/data/parameterized-sql.md +0 -17
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -19
- package/presets/effect/basics/external-calls-are-resilient.md +0 -21
package/src/cli.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import type { Flags } from "#config.ts";
|
|
24
24
|
import { findContradictions, formatContradictions } from "#contradictions.ts";
|
|
25
25
|
import { type Install, initProject } from "#init.ts";
|
|
26
|
+
import { installRules, listRules, parseSource } from "#install.ts";
|
|
26
27
|
import { formatLinterCheck, tallyProjectRules } from "#mechanical.ts";
|
|
27
28
|
import { type PresetName, presetNames } from "#presets.ts";
|
|
28
29
|
import { globalRuleSet } from "#rules.ts";
|
|
@@ -99,6 +100,13 @@ const threshold = Flag.float("threshold").pipe(
|
|
|
99
100
|
),
|
|
100
101
|
);
|
|
101
102
|
|
|
103
|
+
const sufficiencyThreshold = Flag.float("sufficiency-threshold").pipe(
|
|
104
|
+
Flag.optional,
|
|
105
|
+
Flag.withDescription(
|
|
106
|
+
"Warn on a finding when Jev's probability that the file shows enough to decide it is below this value, 0 to 1. Replaces the config's sufficiencyThreshold; the default is 0.6.",
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
|
|
102
110
|
const limit = Flag.integer("limit").pipe(
|
|
103
111
|
Flag.filter(
|
|
104
112
|
(checks) => checks >= 0,
|
|
@@ -219,7 +227,7 @@ export const auditLayer = (flags: Flags, filter: ReadonlyArray<string> = []) =>
|
|
|
219
227
|
/** `adhere lint`: the audit. */
|
|
220
228
|
export const lintCommand = Command.make(
|
|
221
229
|
"lint",
|
|
222
|
-
{ preset, threshold, yes, limit, rpm, filter, denyWarnings },
|
|
230
|
+
{ preset, threshold, sufficiencyThreshold, yes, limit, rpm, filter, denyWarnings },
|
|
223
231
|
(input) =>
|
|
224
232
|
Effect.gen(function* () {
|
|
225
233
|
const stdio = yield* Stdio.Stdio;
|
|
@@ -264,6 +272,7 @@ export const lintCommand = Command.make(
|
|
|
264
272
|
{
|
|
265
273
|
presets: input.preset,
|
|
266
274
|
threshold: Option.getOrUndefined(input.threshold),
|
|
275
|
+
sufficiencyThreshold: Option.getOrUndefined(input.sufficiencyThreshold),
|
|
267
276
|
rpm: Option.getOrUndefined(input.rpm),
|
|
268
277
|
},
|
|
269
278
|
input.filter,
|
|
@@ -318,7 +327,7 @@ export const validateCommand = Command.make("validate", { preset }, () =>
|
|
|
318
327
|
const installLine = (install: Install): string => {
|
|
319
328
|
switch (install.status) {
|
|
320
329
|
case "installed":
|
|
321
|
-
return `installed:
|
|
330
|
+
return `installed: ${install.what} with ${install.packageManager}`;
|
|
322
331
|
case "listed":
|
|
323
332
|
return "installed: none, @drkmttr/adhere is already in package.json";
|
|
324
333
|
case "no-package-json":
|
|
@@ -326,11 +335,25 @@ const installLine = (install: Install): string => {
|
|
|
326
335
|
}
|
|
327
336
|
};
|
|
328
337
|
|
|
338
|
+
const shared = Flag.string("shared").pipe(
|
|
339
|
+
Flag.filter(
|
|
340
|
+
(repo) => /^[\w.-]+\/[\w.-]+$/.test(repo),
|
|
341
|
+
(repo) => `--shared names a GitHub repo as org/repo, not ${repo}.`,
|
|
342
|
+
),
|
|
343
|
+
Flag.optional,
|
|
344
|
+
Flag.withDescription(
|
|
345
|
+
"Scaffold a repo of rules other repos copy with adhere install, named org/repo on GitHub: example rules, a README, a CI workflow that runs adhere validate, and an alchemy.run.ts that creates the repo and its TYPESAFE_API_KEY secret.",
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
|
|
329
349
|
/** `adhere init`: the scaffold. */
|
|
330
|
-
export const initCommand = Command.make("init", { force }, (input) =>
|
|
350
|
+
export const initCommand = Command.make("init", { force, shared }, (input) =>
|
|
331
351
|
Effect.gen(function* () {
|
|
332
352
|
const path = yield* Path.Path;
|
|
333
|
-
const result = yield* initProject(path.resolve(), {
|
|
353
|
+
const result = yield* initProject(path.resolve(), {
|
|
354
|
+
force: input.force,
|
|
355
|
+
...Option.match(input.shared, { onNone: () => ({}), onSome: (repo) => ({ shared: repo }) }),
|
|
356
|
+
});
|
|
334
357
|
const lines = [
|
|
335
358
|
result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
|
|
336
359
|
result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
|
|
@@ -344,6 +367,58 @@ export const initCommand = Command.make("init", { force }, (input) =>
|
|
|
344
367
|
),
|
|
345
368
|
);
|
|
346
369
|
|
|
370
|
+
const source = Argument.string("source").pipe(
|
|
371
|
+
Argument.filterMap(
|
|
372
|
+
(spec) => Option.fromNullishOr(parseSource(spec)),
|
|
373
|
+
(spec) => `org/repo, then optionally a topic or rule and #ref, not ${spec}`,
|
|
374
|
+
),
|
|
375
|
+
Argument.withDescription(
|
|
376
|
+
"A GitHub repo with rules in .adhere/, as org/repo, optionally followed by a topic or a rule, as in org/repo/data/brand-ports, and by #branch or #tag.",
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
/** `adhere list org/repo`: the rules another repo's `.adhere/` holds. */
|
|
381
|
+
export const listCommand = Command.make("list", { source }, (input) =>
|
|
382
|
+
Effect.gen(function* () {
|
|
383
|
+
const rules = yield* listRules(input.source);
|
|
384
|
+
const width = Math.max(...rules.map(({ id }) => id.length));
|
|
385
|
+
yield* Console.log(
|
|
386
|
+
rules.map(({ id, description }) => `${id.padEnd(width)} ${description}`).join("\n"),
|
|
387
|
+
);
|
|
388
|
+
}),
|
|
389
|
+
).pipe(
|
|
390
|
+
Command.withDescription(
|
|
391
|
+
"List the rules in a GitHub repo's .adhere/, each with its description, for adhere install. Clones with git, so a private repo needs git's credentials for it.",
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
/** `adhere install org/repo`: another repo's rules, copied into this one's `.adhere/`. */
|
|
396
|
+
export const installCommand = Command.make(
|
|
397
|
+
"install",
|
|
398
|
+
{
|
|
399
|
+
source,
|
|
400
|
+
force: Flag.boolean("force").pipe(
|
|
401
|
+
Flag.withDefault(false),
|
|
402
|
+
Flag.withDescription("Overwrite rule files already in .adhere/."),
|
|
403
|
+
),
|
|
404
|
+
},
|
|
405
|
+
(input) =>
|
|
406
|
+
Effect.gen(function* () {
|
|
407
|
+
const path = yield* Path.Path;
|
|
408
|
+
const result = yield* installRules(path.resolve(), input.source, { force: input.force });
|
|
409
|
+
yield* Console.log(
|
|
410
|
+
[
|
|
411
|
+
result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
|
|
412
|
+
result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
|
|
413
|
+
].join("\n"),
|
|
414
|
+
);
|
|
415
|
+
}),
|
|
416
|
+
).pipe(
|
|
417
|
+
Command.withDescription(
|
|
418
|
+
"Copy the rules in a GitHub repo's .adhere/, or one topic or rule of them, into this repo's .adhere/<org>/<repo>/. They are this repo's own rules from then on, to edit or keep in step by hand. Existing files are skipped unless --force.",
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
|
|
347
422
|
/**
|
|
348
423
|
* The key typed at a masked prompt, or piped in: `adhere login < key.txt`.
|
|
349
424
|
* Whitespace around it, like the newline a pipe ends with, is dropped.
|
|
@@ -431,6 +506,10 @@ export const cli = Command.make("adhere").pipe(
|
|
|
431
506
|
},
|
|
432
507
|
{ command: "adhere login", description: "Save your TypeSafe AI API key for later runs" },
|
|
433
508
|
{ command: "adhere lint", description: "Audit the working directory" },
|
|
509
|
+
{
|
|
510
|
+
command: "adhere install org/repo",
|
|
511
|
+
description: "Copy another repo's .adhere/ rules into this one",
|
|
512
|
+
},
|
|
434
513
|
{
|
|
435
514
|
command: "adhere lint --preset effect",
|
|
436
515
|
description: "Audit against the built-in Effect rules",
|
|
@@ -440,6 +519,8 @@ export const cli = Command.make("adhere").pipe(
|
|
|
440
519
|
lintCommand,
|
|
441
520
|
validateCommand,
|
|
442
521
|
initCommand,
|
|
522
|
+
listCommand,
|
|
523
|
+
installCommand,
|
|
443
524
|
loginCommand,
|
|
444
525
|
logoutCommand,
|
|
445
526
|
skillCommand,
|
package/src/config.ts
CHANGED
|
@@ -28,6 +28,17 @@ const RuleFields = Schema.Struct({
|
|
|
28
28
|
* findings, which does not.
|
|
29
29
|
*/
|
|
30
30
|
level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
|
|
31
|
+
/**
|
|
32
|
+
* Where the rule applies, each a description of code that breaks it: Jev is
|
|
33
|
+
* asked of each whether any code that breaks the rule is as it describes,
|
|
34
|
+
* and a finding stands only when it says yes to every one.
|
|
35
|
+
*/
|
|
36
|
+
appliesTo: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
37
|
+
/**
|
|
38
|
+
* Where the rule does not apply: Jev is asked of each whether all the code
|
|
39
|
+
* that breaks the rule is as it describes, and a yes to any drops the finding.
|
|
40
|
+
*/
|
|
41
|
+
excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
31
42
|
});
|
|
32
43
|
|
|
33
44
|
/** Before 0.7, a rule's examples were `reference` and `avoid`: read as `must` and `never`. */
|
|
@@ -112,6 +123,12 @@ export const AdhereConfig = Schema.Struct({
|
|
|
112
123
|
* The minimum confidence threshold for a positive match. Defaults to 0.8
|
|
113
124
|
*/
|
|
114
125
|
threshold: Schema.optionalKey(Schema.Finite),
|
|
126
|
+
/**
|
|
127
|
+
* Below this, Jev's probability that a file shows enough to decide a
|
|
128
|
+
* finding's rule adds a warning to the finding: check what the code relies
|
|
129
|
+
* on outside the file. Defaults to 0.6
|
|
130
|
+
*/
|
|
131
|
+
sufficiencyThreshold: Schema.optionalKey(Schema.Finite),
|
|
115
132
|
/**
|
|
116
133
|
* The presets to use for rule matching. Defaults to an empty array. Current presets
|
|
117
134
|
* include "effect" and "alchemy". Subcategories can be expanded by appending `/<subcategory>`.
|
|
@@ -175,13 +192,17 @@ export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
|
175
192
|
/**
|
|
176
193
|
* Directories whose files are tests, matched as whole path segments below the
|
|
177
194
|
* working directory, as skipped directories are. Only a rule that says `tests`
|
|
178
|
-
* judges them, or a `.test
|
|
195
|
+
* judges them, or a `.test` or `.spec` file, such as `a.test.tsx`, anywhere.
|
|
179
196
|
*/
|
|
180
197
|
export const TEST_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
181
198
|
"test",
|
|
182
199
|
"tests",
|
|
183
200
|
"__tests__",
|
|
201
|
+
"testing",
|
|
184
202
|
"fixtures",
|
|
203
|
+
"fixture",
|
|
204
|
+
"__fixtures__",
|
|
205
|
+
"__mocks__",
|
|
185
206
|
]);
|
|
186
207
|
|
|
187
208
|
/** The shape a config file default-exports: the schema's input, where `presets` is optional. */
|
|
@@ -212,11 +233,14 @@ export type Loaded<T extends { readonly rules?: unknown }> = Omit<T, "rules"> &
|
|
|
212
233
|
|
|
213
234
|
export const DEFAULT_MODEL = "jev-latest";
|
|
214
235
|
export const DEFAULT_THRESHOLD = 0.8;
|
|
236
|
+
export const DEFAULT_SUFFICIENCY_THRESHOLD = 0.6;
|
|
215
237
|
|
|
216
238
|
/** A config with its presets folded in and every default applied. */
|
|
217
239
|
export interface ResolvedConfig {
|
|
218
240
|
readonly model: string;
|
|
219
241
|
readonly threshold: number;
|
|
242
|
+
/** Below this, a finding's sufficiency adds a warning to it. */
|
|
243
|
+
readonly sufficiencyThreshold: number;
|
|
220
244
|
readonly rules: Rules;
|
|
221
245
|
readonly scopedRules?: RuleSet;
|
|
222
246
|
/** Requests to Jev a minute, at most. Unset, requests go out as fast as they are made. */
|
|
@@ -232,6 +256,8 @@ export interface Flags {
|
|
|
232
256
|
readonly presets?: ReadonlyArray<PresetName>;
|
|
233
257
|
/** Replaces the config's global threshold. Per-rule thresholds still win. */
|
|
234
258
|
readonly threshold?: number | undefined;
|
|
259
|
+
/** Replaces the config's sufficiency threshold. */
|
|
260
|
+
readonly sufficiencyThreshold?: number | undefined;
|
|
235
261
|
/** Throttles requests to Jev to at most this many a minute. */
|
|
236
262
|
readonly rpm?: number | undefined;
|
|
237
263
|
}
|
|
@@ -271,6 +297,8 @@ export const resolveConfig = (
|
|
|
271
297
|
return {
|
|
272
298
|
model: config.model ?? last("model") ?? DEFAULT_MODEL,
|
|
273
299
|
threshold: flags.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_THRESHOLD,
|
|
300
|
+
sufficiencyThreshold:
|
|
301
|
+
flags.sufficiencyThreshold ?? config.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD,
|
|
274
302
|
rules: Object.assign({}, ...applied.map((preset) => preset.rules), config.rules),
|
|
275
303
|
...(flags.rpm === undefined ? {} : { rpm: flags.rpm }),
|
|
276
304
|
...(config.exclude === undefined ? {} : { exclude: config.exclude }),
|
package/src/excerpt.ts
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import { scan } from "#highlight.ts";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The lines a finding shows: the statement its line is in, such as the
|
|
5
|
-
* function around it, and a few lines on either side of the line.
|
|
6
|
-
*/
|
|
3
|
+
/** The lines a finding shows: the section of the file it is in. */
|
|
7
4
|
export interface Excerpt {
|
|
8
5
|
/** The number of the first line, counting from 1. */
|
|
9
6
|
readonly start: number;
|
|
10
7
|
readonly lines: ReadonlyArray<string>;
|
|
11
8
|
}
|
|
12
9
|
|
|
13
|
-
/** How far an excerpt reaches on either side of the finding's line, in whole statements. */
|
|
14
|
-
const AROUND = 3;
|
|
15
|
-
/** The most lines a statement can have and still be shown whole. */
|
|
16
|
-
const WHOLE = 30;
|
|
17
|
-
|
|
18
10
|
/** Lines of the code, first to last, counting from 1. */
|
|
19
11
|
export interface Range {
|
|
20
12
|
readonly first: number;
|
|
@@ -48,19 +40,13 @@ const ROLES: Readonly<Record<string, Piece["role"]>> = {
|
|
|
48
40
|
",": "separator",
|
|
49
41
|
};
|
|
50
42
|
|
|
51
|
-
/**
|
|
52
|
-
* The code as pieces, without whitespace and comments, and the comments and
|
|
53
|
-
* literals that span lines: a line that starts inside one does not read as
|
|
54
|
-
* code on its own.
|
|
55
|
-
*/
|
|
43
|
+
/** The code as pieces, without whitespace and comments. */
|
|
56
44
|
const piecesOf = (code: string) => {
|
|
57
45
|
const pieces: Array<Piece> = [];
|
|
58
|
-
const spans: Array<Range> = [];
|
|
59
46
|
let line = 1;
|
|
60
47
|
let spaced = true;
|
|
61
48
|
for (const { text, kind } of scan(code)) {
|
|
62
49
|
const end = line + text.split("\n").length - 1;
|
|
63
|
-
if (kind !== undefined && end > line) spans.push({ first: line, last: end });
|
|
64
50
|
if (kind === "comment" || text.trim().length === 0) {
|
|
65
51
|
spaced = true;
|
|
66
52
|
} else if (kind !== undefined || /^[\w$]/.test(text)) {
|
|
@@ -75,7 +61,7 @@ const piecesOf = (code: string) => {
|
|
|
75
61
|
}
|
|
76
62
|
line = end;
|
|
77
63
|
}
|
|
78
|
-
return { pieces
|
|
64
|
+
return { pieces };
|
|
79
65
|
};
|
|
80
66
|
|
|
81
67
|
/**
|
|
@@ -209,6 +195,75 @@ const statementsAround = (
|
|
|
209
195
|
];
|
|
210
196
|
};
|
|
211
197
|
|
|
198
|
+
/** The most lines a section holds, unless one statement with no group to split is longer. */
|
|
199
|
+
const SECTION = 30;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The file in sections, each ending where a statement does: its top-level
|
|
203
|
+
* statements, such as its imports, constants, and functions, packed in
|
|
204
|
+
* order into sections of at most `SECTION` lines, a decorator with what it
|
|
205
|
+
* decorates. A statement longer than that splits into the statements of its
|
|
206
|
+
* largest group, such as a class's methods or a component's body, the lines
|
|
207
|
+
* around that group going with the first and last of them. Every line is in
|
|
208
|
+
* one section: the comments and blank lines above a statement go with it,
|
|
209
|
+
* and the last section runs to the end.
|
|
210
|
+
*/
|
|
211
|
+
export const sectionsOf = (lines: ReadonlyArray<string>): ReadonlyArray<Range> => {
|
|
212
|
+
const { pieces } = piecesOf(lines.join("\n"));
|
|
213
|
+
const closes = closesOf(pieces);
|
|
214
|
+
const lineOf = (index: number) => pieces[index]?.line ?? 0;
|
|
215
|
+
/** Statements as ranges of at most `SECTION` lines where they can be split. */
|
|
216
|
+
const units = (statements: ReadonlyArray<Statement>): ReadonlyArray<Range> =>
|
|
217
|
+
statements.flatMap((statement): ReadonlyArray<Range> => {
|
|
218
|
+
if (statement.last - statement.first < SECTION) return [statement];
|
|
219
|
+
const largest = statement.groups.reduce<readonly [number, number] | undefined>(
|
|
220
|
+
(best, group) =>
|
|
221
|
+
best === undefined ||
|
|
222
|
+
lineOf(group[1]) - lineOf(group[0]) > lineOf(best[1]) - lineOf(best[0])
|
|
223
|
+
? group
|
|
224
|
+
: best,
|
|
225
|
+
undefined,
|
|
226
|
+
);
|
|
227
|
+
const inner =
|
|
228
|
+
largest === undefined
|
|
229
|
+
? []
|
|
230
|
+
: units(statementsIn(pieces, closes, largest[0] + 1, largest[1]));
|
|
231
|
+
const [head, tail] = [inner[0], inner.at(-1)];
|
|
232
|
+
if (head === undefined || tail === undefined || inner.length < 2) return [statement];
|
|
233
|
+
return [
|
|
234
|
+
{ first: statement.first, last: head.last },
|
|
235
|
+
...inner.slice(1, -1),
|
|
236
|
+
{ first: tail.first, last: statement.last },
|
|
237
|
+
];
|
|
238
|
+
});
|
|
239
|
+
const sections: Array<Range> = [];
|
|
240
|
+
/** A decorator, such as `@Command({…})`, waiting for the declaration it decorates. */
|
|
241
|
+
let decorator: Range | undefined;
|
|
242
|
+
for (const found of units(statementsIn(pieces, closes, 0, pieces.length))) {
|
|
243
|
+
if ((lines[found.first - 1] ?? "").trimStart().startsWith("@")) {
|
|
244
|
+
decorator = { first: decorator?.first ?? found.first, last: found.last };
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const unit = { first: decorator?.first ?? found.first, last: found.last };
|
|
248
|
+
decorator = undefined;
|
|
249
|
+
const previous = sections.at(-1);
|
|
250
|
+
if (previous !== undefined && unit.last - previous.first < SECTION) {
|
|
251
|
+
sections[sections.length - 1] = { first: previous.first, last: unit.last };
|
|
252
|
+
} else {
|
|
253
|
+
sections.push({ first: unit.first, last: unit.last });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (decorator !== undefined) sections.push(decorator);
|
|
257
|
+
// A file of comments alone, with no statement, is one section.
|
|
258
|
+
if (sections.length === 0) return lines.length === 0 ? [] : [{ first: 1, last: lines.length }];
|
|
259
|
+
// Each section starts where the one before it ends, and the last one ends with the file.
|
|
260
|
+
return sections.flatMap((section, index): ReadonlyArray<Range> => {
|
|
261
|
+
const first = (sections[index - 1]?.last ?? 0) + 1;
|
|
262
|
+
const last = index === sections.length - 1 ? lines.length : section.last;
|
|
263
|
+
return last < first ? [] : [{ first, last }];
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
|
|
212
267
|
/**
|
|
213
268
|
* The statement that starts on `line`, such as a property, a function, or a
|
|
214
269
|
* call that opens there: the outermost statement around `line` whose first
|
|
@@ -218,48 +273,3 @@ export const statementFrom = (lines: ReadonlyArray<string>, line: number): Range
|
|
|
218
273
|
const { pieces } = piecesOf(lines.join("\n"));
|
|
219
274
|
return statementsAround(pieces, closesOf(pieces), line).find((range) => range.first === line);
|
|
220
275
|
};
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* The excerpt for a finding on `line`: the outermost statement around it
|
|
224
|
-
* with at most `WHOLE` lines, such as the function it is in, and up to
|
|
225
|
-
* `AROUND` lines on either side of `line`. A comment or template literal
|
|
226
|
-
* that spans lines counts as a statement inside the one it is in.
|
|
227
|
-
*
|
|
228
|
-
* Around `line`, the excerpt shows only whole statements, never the end of
|
|
229
|
-
* the one above or the start of the one below, so it cannot start partway
|
|
230
|
-
* through a comment, whose lines do not read as code on their own. What
|
|
231
|
-
* `line` itself is in can show in part: a long function's first line, above
|
|
232
|
-
* the statement of its body. It ends at code, since a comment at the end
|
|
233
|
-
* would be about code the excerpt leaves out.
|
|
234
|
-
*/
|
|
235
|
-
export const excerptOf = (lines: ReadonlyArray<string>, line: number): Excerpt => {
|
|
236
|
-
const { pieces, spans } = piecesOf(lines.join("\n"));
|
|
237
|
-
const closes = closesOf(pieces);
|
|
238
|
-
/** What a line is in, outermost first. */
|
|
239
|
-
const around = (at: number): ReadonlyArray<Range> => [
|
|
240
|
-
...statementsAround(pieces, closes, at),
|
|
241
|
-
...spans.filter((span) => holds(span, at)),
|
|
242
|
-
];
|
|
243
|
-
const scope = around(line).find((range) => range.last - range.first < WHOLE);
|
|
244
|
-
const top = Math.max(1, Math.min(scope?.first ?? line, line - AROUND));
|
|
245
|
-
const bottom = Math.min(lines.length, Math.max(scope?.last ?? line, line + AROUND));
|
|
246
|
-
/** What a line is in that `line` is not, which an edge shows whole or not at all. */
|
|
247
|
-
const beside = (at: number) => around(at).filter((range) => !holds(range, line));
|
|
248
|
-
let first = Math.max(
|
|
249
|
-
top,
|
|
250
|
-
...beside(top)
|
|
251
|
-
.filter((range) => range.first < top)
|
|
252
|
-
.map((range) => range.last + 1),
|
|
253
|
-
);
|
|
254
|
-
let last = Math.min(
|
|
255
|
-
bottom,
|
|
256
|
-
...beside(bottom)
|
|
257
|
-
.filter((range) => range.last > bottom)
|
|
258
|
-
.map((range) => range.first - 1),
|
|
259
|
-
);
|
|
260
|
-
const isBlank = (at: number) => (lines[at - 1] ?? "").trim().length === 0;
|
|
261
|
-
const hasCode = (at: number) => pieces.some((piece) => piece.line <= at && at <= piece.end);
|
|
262
|
-
while (first < line && isBlank(first)) first += 1;
|
|
263
|
-
while (last > line && !hasCode(last)) last -= 1;
|
|
264
|
-
return { start: first, lines: lines.slice(first - 1, last) };
|
|
265
|
-
};
|
package/src/highlight.ts
CHANGED
|
@@ -30,6 +30,8 @@ const CLASSES = ["comment", "string", "constant"] as const;
|
|
|
30
30
|
/** One group per token class, tried in order at each position. A template literal is read apart. */
|
|
31
31
|
const TOKEN = new RegExp(
|
|
32
32
|
[
|
|
33
|
+
// A URL outside a string is JSX text, as in `<a>https://example.com</a>`, and its `//` no comment.
|
|
34
|
+
/(?<url>[a-z][\w+.-]*:\/\/[^\s<>{}"'`]*)/,
|
|
33
35
|
/(?<comment>\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))/,
|
|
34
36
|
/(?<string>"(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'?)/,
|
|
35
37
|
/(?<constant>0[box][\da-f_]+n?|\d[\d_]*(?:\.\d[\d_]*)?(?:e[+-]?\d+)?n?)/,
|
package/src/init.ts
CHANGED
|
@@ -12,12 +12,22 @@ export interface InitResult {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export type Install =
|
|
15
|
-
| {
|
|
15
|
+
| {
|
|
16
|
+
readonly status: "installed";
|
|
17
|
+
/** @drkmttr/adhere, added, or in a shared repo, package.json, installed. */
|
|
18
|
+
readonly what: string;
|
|
19
|
+
readonly packageManager: PackageManager;
|
|
20
|
+
}
|
|
16
21
|
| { readonly status: "listed" }
|
|
17
22
|
| { readonly status: "no-package-json" };
|
|
18
23
|
|
|
19
24
|
export interface InitOptions {
|
|
20
25
|
readonly force?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* `org/repo`: scaffold a repo whose rules other repos copy with `adhere
|
|
28
|
+
* install`, rather than one that lints itself.
|
|
29
|
+
*/
|
|
30
|
+
readonly shared?: string;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
// adhere-ignore-file rules/import-written-files -- src/index.ts exports initProject, so a Bun text import here breaks loading the package on Node and in Vitest (checked 2026-09-26), and the compiled executable has no files beside it to read.
|
|
@@ -100,18 +110,155 @@ export const handle = (id: string) =>
|
|
|
100
110
|
\`\`\`
|
|
101
111
|
`;
|
|
102
112
|
|
|
113
|
+
const sharedReadme = (org: string, repo: string) => `# ${repo}
|
|
114
|
+
|
|
115
|
+
Shared [adhere](https://github.com/darkmatter/adhere) rules for ${org}'s
|
|
116
|
+
repositories. Each rule is a Markdown file in \`.adhere/\`, and a repo that
|
|
117
|
+
copies it in has Jev judge its files against it when it runs \`adhere lint\`.
|
|
118
|
+
|
|
119
|
+
## Use these rules
|
|
120
|
+
|
|
121
|
+
List the rules, then copy them into a repo, all of them or one topic or rule:
|
|
122
|
+
|
|
123
|
+
\`\`\`sh
|
|
124
|
+
adhere list ${org}/${repo}
|
|
125
|
+
adhere install ${org}/${repo}
|
|
126
|
+
adhere install ${org}/${repo}/style
|
|
127
|
+
adhere install ${org}/${repo}/style/prefer-small-files
|
|
128
|
+
\`\`\`
|
|
129
|
+
|
|
130
|
+
The copies land in \`.adhere/${org}/${repo}/\` and are that repo's own from then
|
|
131
|
+
on, to edit or to start other rules from. To take a later version of a rule,
|
|
132
|
+
install it again with \`--force\`, which overwrites local edits.
|
|
133
|
+
|
|
134
|
+
## Add a rule
|
|
135
|
+
|
|
136
|
+
Add a Markdown file under \`.adhere/<topic>/\`; its path is its id. See
|
|
137
|
+
[Rules as Markdown files](https://github.com/darkmatter/adhere#rules-as-markdown-files)
|
|
138
|
+
and the [rule writing tips](https://github.com/darkmatter/adhere#rule-writing-tips).
|
|
139
|
+
\`adhere validate\` checks each rule's wording and asks Jev whether any two
|
|
140
|
+
contradict. CI runs it on every push to main and every pull request.
|
|
141
|
+
|
|
142
|
+
## Setup
|
|
143
|
+
|
|
144
|
+
\`alchemy.run.ts\` creates this repository on GitHub, or adopts it when it
|
|
145
|
+
exists, and sets the \`TYPESAFE_API_KEY\` secret CI's \`adhere validate\` needs.
|
|
146
|
+
Install, which init already did, deploy once, then push:
|
|
147
|
+
|
|
148
|
+
\`\`\`sh
|
|
149
|
+
npm install
|
|
150
|
+
TYPESAFE_API_KEY=... npx alchemy deploy
|
|
151
|
+
git init && git add -A && git commit -m "Add shared adhere rules"
|
|
152
|
+
git remote add origin git@github.com:${org}/${repo}.git && git push -u origin main
|
|
153
|
+
\`\`\`
|
|
154
|
+
|
|
155
|
+
Deploying again updates the secret, as after rotating the key.
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
const SHARED_WORKFLOW = `name: adhere
|
|
159
|
+
|
|
160
|
+
on:
|
|
161
|
+
pull_request:
|
|
162
|
+
push:
|
|
163
|
+
branches: [main]
|
|
164
|
+
|
|
165
|
+
jobs:
|
|
166
|
+
validate:
|
|
167
|
+
runs-on: ubuntu-latest
|
|
168
|
+
steps:
|
|
169
|
+
- uses: actions/checkout@v4
|
|
170
|
+
- run: npx --yes @drkmttr/adhere validate
|
|
171
|
+
env:
|
|
172
|
+
TYPESAFE_API_KEY: \${{ secrets.TYPESAFE_API_KEY }}
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
const sharedStack = (org: string, repo: string) => `import * as Alchemy from "alchemy";
|
|
176
|
+
import * as GitHub from "alchemy/GitHub";
|
|
177
|
+
import * as Output from "alchemy/Output";
|
|
178
|
+
import * as Config from "effect/Config";
|
|
179
|
+
import * as Effect from "effect/Effect";
|
|
180
|
+
|
|
181
|
+
const owner = "${org}";
|
|
182
|
+
|
|
183
|
+
export default Alchemy.Stack(
|
|
184
|
+
"${repo}",
|
|
185
|
+
{ providers: GitHub.providers(), state: Alchemy.localState() },
|
|
186
|
+
Effect.gen(function* () {
|
|
187
|
+
const repository = yield* GitHub.Repository("rules", {
|
|
188
|
+
owner,
|
|
189
|
+
name: "${repo}",
|
|
190
|
+
description: "Shared adhere rules",
|
|
191
|
+
visibility: "private",
|
|
192
|
+
});
|
|
193
|
+
// CI's adhere validate asks Jev whether any two rules contradict. Naming
|
|
194
|
+
// the repository by its output makes alchemy create it before the secret.
|
|
195
|
+
yield* GitHub.Secret("typesafe-api-key", {
|
|
196
|
+
owner,
|
|
197
|
+
repository: Output.map(repository.fullName, (fullName) => fullName.split("/")[1] ?? ""),
|
|
198
|
+
name: "TYPESAFE_API_KEY",
|
|
199
|
+
value: yield* Config.Redacted("TYPESAFE_API_KEY"),
|
|
200
|
+
});
|
|
201
|
+
return { url: repository.htmlUrl };
|
|
202
|
+
}).pipe(Effect.orDie),
|
|
203
|
+
);
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* alchemy's CLI runs on effect and its Node platform. They are pinned, as is
|
|
208
|
+
* the platform's own dependency, since a range of prereleases takes the newest:
|
|
209
|
+
* effect 4.0.0-rc.118 moved modules alchemy 2.0.0-beta.79 imports (checked
|
|
210
|
+
* 2026-09-28).
|
|
211
|
+
*/
|
|
212
|
+
const sharedPackage = (repo: string) =>
|
|
213
|
+
`${JSON.stringify(
|
|
214
|
+
{
|
|
215
|
+
name: repo,
|
|
216
|
+
private: true,
|
|
217
|
+
type: "module",
|
|
218
|
+
devDependencies: {
|
|
219
|
+
"@effect/platform-node": "4.0.0-rc.117",
|
|
220
|
+
alchemy: "2.0.0-beta.79",
|
|
221
|
+
effect: "4.0.0-rc.117",
|
|
222
|
+
},
|
|
223
|
+
overrides: { "@effect/platform-node-shared": "4.0.0-rc.117" },
|
|
224
|
+
},
|
|
225
|
+
null,
|
|
226
|
+
2,
|
|
227
|
+
)}\n`;
|
|
228
|
+
|
|
229
|
+
const SHARED_GITIGNORE = `node_modules/
|
|
230
|
+
.alchemy/
|
|
231
|
+
`;
|
|
232
|
+
|
|
103
233
|
const CONFIG_PATH = CONFIG_FILES[0];
|
|
104
234
|
|
|
105
235
|
/** The config's own project, which only a config at `.adhere/config.ts` needs. */
|
|
106
236
|
const CONFIG_PROJECT = ".adhere/tsconfig.json";
|
|
107
237
|
|
|
108
|
-
const
|
|
109
|
-
[CONFIG_PATH, CONFIG],
|
|
110
|
-
[CONFIG_PROJECT, TSCONFIG],
|
|
238
|
+
const EXAMPLES = [
|
|
111
239
|
[".adhere/style/prefer-small-files.md", SMALL_FILES],
|
|
112
240
|
[".adhere/style/name-domain-actions.md", NAME_EFFECTS],
|
|
113
241
|
] as const;
|
|
114
242
|
|
|
243
|
+
/**
|
|
244
|
+
* A shared repo has no config: \`adhere install\` copies only rule files, and
|
|
245
|
+
* \`validate\` reads \`.adhere/\` without one.
|
|
246
|
+
*/
|
|
247
|
+
const filesFor = (shared: string | undefined): ReadonlyArray<readonly [string, string]> => {
|
|
248
|
+
if (shared === undefined) {
|
|
249
|
+
return [[CONFIG_PATH, CONFIG], [CONFIG_PROJECT, TSCONFIG], ...EXAMPLES];
|
|
250
|
+
}
|
|
251
|
+
const [org = "", repo = ""] = shared.split("/");
|
|
252
|
+
return [
|
|
253
|
+
["README.md", sharedReadme(org, repo)],
|
|
254
|
+
[".github/workflows/adhere.yaml", SHARED_WORKFLOW],
|
|
255
|
+
["alchemy.run.ts", sharedStack(org, repo)],
|
|
256
|
+
["package.json", sharedPackage(repo)],
|
|
257
|
+
[".gitignore", SHARED_GITIGNORE],
|
|
258
|
+
...EXAMPLES,
|
|
259
|
+
];
|
|
260
|
+
};
|
|
261
|
+
|
|
115
262
|
const exists = (path: string): Promise<boolean> =>
|
|
116
263
|
access(path).then(
|
|
117
264
|
() => true,
|
|
@@ -177,19 +324,27 @@ const addFlags: Record<PackageManager, ReadonlyArray<string>> = {
|
|
|
177
324
|
npm: [],
|
|
178
325
|
};
|
|
179
326
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
327
|
+
/**
|
|
328
|
+
* Adds adhere to devDependencies, so the config's `import type` resolves. A
|
|
329
|
+
* shared repo's package.json, which init writes, lists what it needs already,
|
|
330
|
+
* so there it installs that instead.
|
|
331
|
+
*/
|
|
332
|
+
const installPackages = (root: string, shared: boolean) =>
|
|
182
333
|
Effect.tryPromise({
|
|
183
334
|
try: async (): Promise<Install> => {
|
|
184
335
|
const manifest = await readFile(join(root, "package.json"), "utf8").catch(() => undefined);
|
|
185
336
|
if (manifest === undefined) return { status: "no-package-json" };
|
|
186
337
|
const { dependencies, devDependencies } = JSON.parse(manifest);
|
|
187
|
-
if (
|
|
338
|
+
if (
|
|
339
|
+
!shared &&
|
|
340
|
+
(dependencies?.[PACKAGE] !== undefined || devDependencies?.[PACKAGE] !== undefined)
|
|
341
|
+
) {
|
|
188
342
|
return { status: "listed" };
|
|
189
343
|
}
|
|
190
344
|
const packageManager = await packageManagerAt(root);
|
|
345
|
+
const args = shared ? ["install"] : ["add", "-D", PACKAGE, ...addFlags[packageManager]];
|
|
191
346
|
await new Promise<void>((resolve, reject) => {
|
|
192
|
-
spawn(packageManager,
|
|
347
|
+
spawn(packageManager, args, {
|
|
193
348
|
cwd: root,
|
|
194
349
|
stdio: "inherit",
|
|
195
350
|
})
|
|
@@ -198,11 +353,11 @@ const installAdhere = (root: string) =>
|
|
|
198
353
|
code === 0 ? resolve() : reject(new Error(`${packageManager} exited with ${code}`)),
|
|
199
354
|
);
|
|
200
355
|
});
|
|
201
|
-
return { status: "installed", packageManager };
|
|
356
|
+
return { status: "installed", what: shared ? "package.json" : PACKAGE, packageManager };
|
|
202
357
|
},
|
|
203
358
|
catch: (cause) =>
|
|
204
359
|
new Error(
|
|
205
|
-
`Could not
|
|
360
|
+
`Could not install ${shared ? "package.json" : PACKAGE}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
206
361
|
),
|
|
207
362
|
});
|
|
208
363
|
|
|
@@ -213,8 +368,8 @@ export const initProject = (
|
|
|
213
368
|
Effect.gen(function* () {
|
|
214
369
|
const created: Array<string> = [];
|
|
215
370
|
const skipped: Array<string> = [];
|
|
216
|
-
const existing = yield* otherConfig(root);
|
|
217
|
-
for (const [path, contents] of
|
|
371
|
+
const existing = options.shared === undefined ? yield* otherConfig(root) : undefined;
|
|
372
|
+
for (const [path, contents] of filesFor(options.shared)) {
|
|
218
373
|
if (path === CONFIG_PATH && existing !== undefined) {
|
|
219
374
|
skipped.push(existing);
|
|
220
375
|
continue;
|
|
@@ -228,6 +383,6 @@ export const initProject = (
|
|
|
228
383
|
skipped.push(relative(root, join(root, path)));
|
|
229
384
|
}
|
|
230
385
|
}
|
|
231
|
-
const install = yield*
|
|
386
|
+
const install = yield* installPackages(root, options.shared !== undefined);
|
|
232
387
|
return { created, skipped, install };
|
|
233
388
|
});
|