@drkmttr/adhere 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +226 -41
  2. package/package.json +7 -7
  3. package/presets/effect/basics/gen-for-sequencing.md +1 -1
  4. package/presets/effect/data/brand-meaningful-primitives.md +1 -1
  5. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  6. package/presets/effect/services/dependencies-through-layers.md +1 -1
  7. package/presets/react/effects/clean-up-what-they-start.md +23 -0
  8. package/presets/react/effects/event-logic-in-handlers.md +29 -0
  9. package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
  10. package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
  11. package/presets/react/effects/notify-parents-in-handlers.md +29 -0
  12. package/presets/react/server/no-private-data-to-client.md +23 -0
  13. package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
  14. package/presets/react/state/no-contradictory-state.md +17 -0
  15. package/presets/react/state/no-props-copied-into-state.md +23 -0
  16. package/presets/react/state/store-ids-not-copies.md +20 -0
  17. package/presets/security/constant-time-secret-compare.md +22 -0
  18. package/presets/security/no-secrets-in-output.md +19 -0
  19. package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
  20. package/presets/security/parameterized-queries.md +16 -0
  21. package/presets/typescript/async/concurrent-independent-work.md +23 -0
  22. package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
  23. package/presets/typescript/design/no-argument-mutation.md +20 -0
  24. package/presets/typescript/errors/no-swallowed-errors.md +29 -0
  25. package/presets/typescript/errors/release-on-every-path.md +29 -0
  26. package/presets/typescript/testing/assert-outcomes.md +22 -0
  27. package/presets/typescript/testing/independent-tests.md +41 -0
  28. package/presets/typescript/types/assertions-only-when-proven.md +20 -0
  29. package/presets/typescript/types/derive-dont-restate.md +24 -0
  30. package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
  31. package/presets/typescript/types/validate-external-data.md +21 -0
  32. package/skills/adhere/SKILL.md +4 -3
  33. package/skills/adhere-fix/SKILL.md +10 -4
  34. package/src/cli.ts +85 -4
  35. package/src/config.ts +29 -1
  36. package/src/excerpt.ts +72 -62
  37. package/src/highlight.ts +2 -0
  38. package/src/init.ts +168 -13
  39. package/src/install.ts +147 -0
  40. package/src/markdown.ts +6 -0
  41. package/src/presets.ts +6 -0
  42. package/src/rules.ts +1 -1
  43. package/src/services/AuditCache.ts +31 -5
  44. package/src/services/Jev.http.ts +76 -11
  45. package/src/services/Jev.ts +191 -73
  46. package/src/services/SourceWalker.ts +23 -7
  47. package/src/suppress.ts +13 -7
  48. package/src/version.ts +25 -1
  49. package/src/workflows/audit.ts +96 -26
  50. package/src/workflows/format.ts +51 -11
  51. package/presets/alchemy/data/parameterized-sql.md +0 -17
  52. package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -19
  53. package/presets/effect/basics/external-calls-are-resilient.md +0 -21
package/src/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: @drkmttr/adhere with ${install.packageManager}`;
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(), { force: input.force });
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.ts` or `.spec.ts` file anywhere.
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, spans };
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
- | { readonly status: "installed"; readonly packageManager: PackageManager }
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 files = [
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
- /** Adds adhere to devDependencies, so the config's `import type` resolves. */
181
- const installAdhere = (root: string) =>
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 (dependencies?.[PACKAGE] !== undefined || devDependencies?.[PACKAGE] !== undefined) {
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, ["add", "-D", PACKAGE, ...addFlags[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 add ${PACKAGE} to package.json: ${cause instanceof Error ? cause.message : String(cause)}`,
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 files) {
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* installAdhere(root);
386
+ const install = yield* installPackages(root, options.shared !== undefined);
232
387
  return { created, skipped, install };
233
388
  });