@avi2dg/checks 0.31.0 → 0.33.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 (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -3
  3. package/dist/readability/index.js +18 -9
  4. package/docs/configs/commit-messages.md +5 -0
  5. package/docs/configs/typescript-rules.md +1 -1
  6. package/docs/design.md +42 -4
  7. package/docs/gates/checks-commit-identity.md +3 -0
  8. package/docs/gates/checks-docs.md +9 -3
  9. package/docs/gates/checks-lint.md +1 -1
  10. package/docs/gates/checks-mutation.md +2 -1
  11. package/docs/gates/checks-release-notes.md +10 -1
  12. package/docs/gates/checks-release-pr.md +185 -0
  13. package/docs/gates/checks-release-report.md +4 -2
  14. package/docs/gates/checks-release-tag.md +63 -0
  15. package/docs/gates/checks-secrets.md +93 -0
  16. package/docs/gates/checks-vendor.md +3 -3
  17. package/package.json +23 -6
  18. package/src/core/gates.ts +1 -0
  19. package/src/core/git.ts +1 -1
  20. package/src/core/lint.ts +1 -1
  21. package/src/delivery/commit-identity.ts +17 -1
  22. package/src/delivery/github.ts +38 -0
  23. package/src/delivery/gitleaks.toml +113 -0
  24. package/src/delivery/gitleaks.ts +111 -0
  25. package/src/delivery/release-pr.ts +196 -0
  26. package/src/delivery/release-report.ts +3 -30
  27. package/src/delivery/release-tag.ts +64 -0
  28. package/src/delivery/release.ts +79 -0
  29. package/src/delivery/secrets.ts +86 -0
  30. package/src/dependencies/osv-scanner.ts +3 -45
  31. package/src/dependencies/pinned-binary.ts +80 -0
  32. package/src/docs/doc-agents.ts +22 -20
  33. package/src/docs/doc-names.ts +1 -1
  34. package/src/docs/doc-references.ts +14 -7
  35. package/src/docs/docs.ts +15 -2
  36. package/src/docs/prose-matchers.ts +1 -1
  37. package/src/docs/sentence-length.ts +106 -0
  38. package/src/testing/flake.ts +1 -1
  39. package/src/testing/mutation.ts +1 -1
  40. package/src/testing/test.ts +1 -1
@@ -1,11 +1,9 @@
1
+ import { Parser, type NodeType } from "commonmark";
1
2
  import { commandNames, type Snapshot } from "./doc-references.ts";
2
3
  import { AGENT_NAMES, scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
3
4
 
4
5
  export const AGENT_CEILING = 3000;
5
6
 
6
- const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
7
- const INDENT = /^[ \t]*/;
8
- const CODE_INDENT = 4;
9
7
  const INLINE_LINK =
10
8
  /(?<![!\\])\[(?:[^[\]\\]|\\.|\[[^\]]*\])*\]\(\s*(?:<[^<>\n]+>|[^\s()<>]+(?:\([^\s()]*\)[^\s()<>]*)*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
11
9
  const MAINTAINING = /^\s{0,3}#{1,6}\s+Maintaining this file(?:\s+#+)?\s*$/;
@@ -27,8 +25,24 @@ export function ceilingFinding(text: string): AgentFinding | undefined {
27
25
  };
28
26
  }
29
27
 
28
+ function blockStarts(lines: readonly MarkdownLine[], type: NodeType): ReadonlySet<number> {
29
+ const read = lines.map(({ kind, raw }) => (kind === "front-matter" ? "" : raw)).join("\n");
30
+ const walker = new Parser().parse(read).walker();
31
+ const starts = new Set<number>();
32
+ for (let step = walker.next(); step !== null; step = walker.next()) {
33
+ if (step.entering && step.node.type === type) starts.add(step.node.sourcepos[0][0]);
34
+ }
35
+ return starts;
36
+ }
37
+
38
+ function linesOpening(text: string, type: NodeType): readonly MarkdownLine[] {
39
+ const lines = scanMarkdown(text);
40
+ const starts = blockStarts(lines, type);
41
+ return lines.filter(({ line }) => starts.has(line)).flatMap(({ line, raw }) => scanMarkdown(raw).map((alone) => ({ ...alone, line })));
42
+ }
43
+
30
44
  export function maintainingFinding(text: string): AgentFinding | undefined {
31
- const heading = scanMarkdown(text).find(({ kind, raw }) => kind === "heading" && MAINTAINING.test(raw));
45
+ const heading = linesOpening(text, "heading").find(({ raw }) => MAINTAINING.test(raw));
32
46
  if (heading === undefined) return undefined;
33
47
  return {
34
48
  line: heading.line,
@@ -36,27 +50,14 @@ export function maintainingFinding(text: string): AgentFinding | undefined {
36
50
  };
37
51
  }
38
52
 
39
- function indentOf(raw: string): number {
40
- return (INDENT.exec(raw)?.[0] ?? "").replaceAll("\t", " ").length;
41
- }
42
-
43
53
  export function entries(text: string): readonly MarkdownLine[] {
44
- let inList = false;
45
- let afterBlank = true;
46
- return scanMarkdown(text).filter((line) => {
47
- const blank = line.raw.trim() === "";
48
- const indented = !blank && indentOf(line.raw) >= CODE_INDENT && !line.raw.trimStart().startsWith(">");
49
- const listItem = line.kind === "prose" && LIST_ITEM.test(line.prose) && (inList || !indented);
50
- if (listItem) inList = true;
51
- else if (!blank && !indented && (afterBlank || line.kind !== "prose")) inList = false;
52
- afterBlank = blank;
53
- return listItem;
54
- });
54
+ return linesOpening(text, "item");
55
55
  }
56
56
 
57
57
  type Tracked = Pick<Snapshot, "files" | "directories">;
58
58
 
59
59
  const STAYS = new Set(["", "."]);
60
+ const NAMES_A_SEGMENT = /[^/]/;
60
61
 
61
62
  function joined(directory: string, segment: string): string {
62
63
  return directory === "" ? segment : `${directory}/${segment}`;
@@ -76,10 +77,11 @@ function resolvesFrom(directory: string, span: string, tracked: Tracked): boolea
76
77
  if (walked === undefined) return false;
77
78
  if (!STAYS.has(last) && last !== ".." && tracked.files.has(joined(walked, last))) return true;
78
79
  const end = enter(walked, last, tracked);
79
- return end !== undefined && end !== "" && end !== directory;
80
+ return end !== undefined && end !== "";
80
81
  }
81
82
 
82
83
  function namesTrackedPath(agentFile: string, span: string, tracked: Tracked): boolean {
84
+ if (!NAMES_A_SEGMENT.test(span)) return false;
83
85
  const directory = agentFile.includes("/") ? agentFile.slice(0, agentFile.lastIndexOf("/")) : "";
84
86
  const fromFile = resolvesFrom(directory, span, tracked);
85
87
  return fromFile || (!span.startsWith("./") && !span.startsWith("../") && resolvesFrom("", span, tracked));
@@ -1,5 +1,5 @@
1
1
  import { Effect, FileSystem, Option, Path, Schema } from "effect";
2
- import { ChildProcessSpawner } from "effect/unstable/process";
2
+ import { ChildProcessSpawner } from "effect/process";
3
3
  import { collect, git, pathsAt } from "../core/git.ts";
4
4
  import type { Unresolved } from "./doc-references.ts";
5
5
  import { scanMarkdown } from "./prose-matchers.ts";
@@ -78,20 +78,28 @@ function unresolvedPath(doc: string, line: number, span: string, snapshot: Snaps
78
78
  const SCHEME = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
79
79
  const ASCII_ESCAPE = /%([0-7][0-9a-f])/gi;
80
80
 
81
+ function linkPath(doc: string, target: string, hash: number): string | undefined {
82
+ const written = (hash < 0 ? target : target.slice(0, hash)).split("?", 1)[0] ?? "";
83
+ const decoded = written.replace(ASCII_ESCAPE, (_, code: string) => String.fromCharCode(Number.parseInt(code, 16)));
84
+ return decoded === "" ? doc : decoded.startsWith("/") ? normalize(decoded) : within(directoryOf(doc), decoded);
85
+ }
86
+
81
87
  function unresolvedLink(doc: string, line: number, target: string, snapshot: Snapshot): Unresolved | undefined {
82
88
  if (target === "" || SCHEME.test(target)) return undefined;
83
89
  const hash = target.indexOf("#");
84
- const written = (hash < 0 ? target : target.slice(0, hash)).split("?", 1)[0] ?? "";
85
- const decoded = written.replace(ASCII_ESCAPE, (_, code: string) => String.fromCharCode(Number.parseInt(code, 16)));
86
- const path = decoded === "" ? doc : decoded.startsWith("/") ? normalize(decoded) : within(directoryOf(doc), decoded);
90
+ const path = linkPath(doc, target, hash);
87
91
  if (path === undefined) return undefined;
88
92
  const named = target;
89
93
  if (!exists(snapshot, path)) {
90
94
  return { kind: "link", line, named, message: `links to \`${named}\`, which is not in the repository`, missing: { type: "file", path } };
91
95
  }
92
96
  const anchor = hash < 0 ? "" : target.slice(hash + 1);
97
+ if (anchor === "") return undefined;
93
98
  const anchors = snapshot.anchors.get(path);
94
- if (anchor === "" || anchors === undefined || anchors.has(anchor) || anchors.has(anchor.toLowerCase())) return undefined;
99
+ if (anchors === undefined) {
100
+ return { kind: "link", line, named, message: `links to \`${named}\`, and \`${path}\` has no headings to check`, missing: { type: "anchor" } };
101
+ }
102
+ if (anchors.has(anchor) || anchors.has(anchor.toLowerCase())) return undefined;
95
103
  return { kind: "link", line, named, message: `links to \`${named}\`, and \`${path}\` has no heading with that anchor`, missing: { type: "anchor" } };
96
104
  }
97
105
 
@@ -152,9 +160,8 @@ export function anchoredTargets(doc: string, text: string): readonly string[] {
152
160
  links.flatMap((target) => {
153
161
  const hash = target.indexOf("#");
154
162
  if (hash < 0 || SCHEME.test(target)) return [];
155
- const written = target.slice(0, hash);
156
- const path = written === "" ? doc : written.startsWith("/") ? normalize(written) : within(directoryOf(doc), written);
157
- return path?.endsWith(".md") === true ? [path] : [];
163
+ const path = linkPath(doc, target, hash);
164
+ return path !== undefined && (path.endsWith(".md") || path.endsWith(".mdx")) ? [path] : [];
158
165
  }),
159
166
  );
160
167
  }
package/src/docs/docs.ts CHANGED
@@ -8,6 +8,7 @@ import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
8
8
  import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
9
9
  import { runMain } from "../core/main.ts";
10
10
  import { proseFindings, readerOf } from "./prose-matchers.ts";
11
+ import { longSentences } from "./sentence-length.ts";
11
12
 
12
13
  type Finding = {
13
14
  readonly path: string;
@@ -22,6 +23,7 @@ type Judged = {
22
23
  readonly agents: number;
23
24
  readonly findings: readonly Finding[];
24
25
  readonly advisory: ReadonlyMap<string, number>;
26
+ readonly sentenceAdvisory: readonly Finding[];
25
27
  readonly brokenBefore: readonly Finding[];
26
28
  };
27
29
 
@@ -105,6 +107,9 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
105
107
  const prose = edited.flatMap(({ path, reader }) =>
106
108
  proseFindings(text(path), reader, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
107
109
  );
110
+ const sentenceAdvisory = edited
111
+ .flatMap(({ path }) => longSentences(text(path), changed.get(path)).map(({ line, message }) => ({ path, line, message })))
112
+ .toSorted(inPathOrder);
108
113
  const judging = (path: string): Judging => ({ commands: !speaksToConsumers(text(path)) });
109
114
  // A directory the range deletes still belongs to this repository, so a path under it is stale rather than another repository's.
110
115
  const roots = rootsOf(yield* pathsAt(base, [], root));
@@ -133,6 +138,7 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
133
138
  ...entries,
134
139
  ].toSorted(inPathOrder),
135
140
  advisory,
141
+ sentenceAdvisory,
136
142
  brokenBefore: references.brokenBefore.toSorted(inPathOrder),
137
143
  } satisfies Judged;
138
144
  });
@@ -141,7 +147,7 @@ function describe({ path, line, message }: Finding): string {
141
147
  return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
142
148
  }
143
149
 
144
- export function report({ held, edited, named, agents, findings, advisory, brokenBefore }: Judged): string {
150
+ export function report({ held, edited, named, agents, findings, advisory, sentenceAdvisory, brokenBefore }: Judged): string {
145
151
  const verdict =
146
152
  findings.length === 0
147
153
  ? [
@@ -158,6 +164,13 @@ export function report({ held, edited, named, agents, findings, advisory, broken
158
164
  `${NAME}: advisory, ${advisory.size} doc file(s) the range leaves alone do not hold to their templates yet:`,
159
165
  ...[...advisory].map(([path, count]) => ` ${path}: ${count} violation(s)`),
160
166
  ];
167
+ const sentences =
168
+ sentenceAdvisory.length === 0
169
+ ? []
170
+ : [
171
+ `${NAME}: advisory, ${sentenceAdvisory.length} sentence(s) over the trial length caps:`,
172
+ ...sentenceAdvisory.map(describe),
173
+ ];
161
174
  const broken =
162
175
  brokenBefore.length === 0
163
176
  ? []
@@ -165,7 +178,7 @@ export function report({ held, edited, named, agents, findings, advisory, broken
165
178
  `${NAME}: advisory, ${brokenBefore.length} path(s), link(s) or command(s) the living docs or agent files name were broken before the range:`,
166
179
  ...brokenBefore.map(describe),
167
180
  ];
168
- return [...verdict, ...unconformed, ...broken].join("\n");
181
+ return [...verdict, ...unconformed, ...sentences, ...broken].join("\n");
169
182
  }
170
183
 
171
184
  const docs = Effect.gen(function* () {
@@ -243,7 +243,7 @@ export function scanMarkdown(text: string): readonly MarkdownLine[] {
243
243
 
244
244
  const LEADING_MARKERS = /^(?:\s*>)*\s*(?:#{1,6}\s+|(?:[-*+]|\d{1,9}[.)])\s+(?:\[[ xX]\]\s+)?)?/;
245
245
 
246
- function bodyOf({ prose }: MarkdownLine): string {
246
+ export function bodyOf({ prose }: MarkdownLine): string {
247
247
  const markers = LEADING_MARKERS.exec(prose)?.[0] ?? "";
248
248
  return BLANK.repeat(markers.length) + prose.slice(markers.length);
249
249
  }
@@ -0,0 +1,106 @@
1
+ import { bodyOf, scanMarkdown } from "./prose-matchers.ts";
2
+
3
+ export const PROCEDURAL_WORD_CAP = 20;
4
+ export const DESCRIPTIVE_WORD_CAP = 25;
5
+
6
+ export type SentenceKind = "procedural" | "descriptive";
7
+
8
+ export type SentenceLength = {
9
+ readonly line: number;
10
+ readonly words: number;
11
+ readonly kind: SentenceKind;
12
+ };
13
+
14
+ export type LongSentence = SentenceLength & {
15
+ readonly message: string;
16
+ };
17
+
18
+ function graphemesOf(body: string): readonly string[] {
19
+ return [...new Intl.Segmenter().segment(body)].map(({ segment }) => segment);
20
+ }
21
+
22
+ function dropPrefix(raw: string): string {
23
+ const chars = graphemesOf(raw);
24
+ for (const [at, char] of chars.entries()) {
25
+ if (char !== " " && char !== "\t" && char !== ">") return chars.slice(at).join("");
26
+ }
27
+ return "";
28
+ }
29
+
30
+ function orderedAt(raw: string): boolean {
31
+ const rest = dropPrefix(raw);
32
+ const digits = /^\d+/.exec(rest)?.[0] ?? "";
33
+ if (digits === "") return false;
34
+ const marker = rest.charAt(digits.length);
35
+ if (marker !== "." && marker !== ")") return false;
36
+ const after = rest.charAt(digits.length + 1);
37
+ return after === " " || after === "\t" || after === "";
38
+ }
39
+
40
+ function isWordChar(char: string): boolean {
41
+ return (char >= "0" && char <= "9") || (char >= "A" && char <= "Z") || (char >= "a" && char <= "z");
42
+ }
43
+
44
+ function wordsIn(sentence: string): number {
45
+ let words = 0;
46
+ let inToken = false;
47
+ let holdsWordChar = false;
48
+ for (const char of sentence) {
49
+ if (char === " " || char === "\t") {
50
+ if (inToken && holdsWordChar) words += 1;
51
+ inToken = false;
52
+ holdsWordChar = false;
53
+ } else {
54
+ inToken = true;
55
+ if (isWordChar(char)) holdsWordChar = true;
56
+ }
57
+ }
58
+ return inToken && holdsWordChar ? words + 1 : words;
59
+ }
60
+
61
+ function isCloser(char: string): boolean {
62
+ return char === '"' || char === "'" || char === ")" || char === "]" || char === "*" || char === "_" || char === "”" || char === "’";
63
+ }
64
+
65
+ function sentencesIn(body: string): readonly string[] {
66
+ const chars = graphemesOf(body);
67
+ const sentences: string[] = [];
68
+ let start = 0;
69
+ let cut = -1;
70
+ for (const [at, char] of chars.entries()) {
71
+ if (char === "." || char === "!" || char === "?") cut = at + 1;
72
+ else if (cut === at && isCloser(char)) cut = at + 1;
73
+ else if (cut > start && (char === " " || char === "\t")) {
74
+ sentences.push(chars.slice(start, cut).join(""));
75
+ start = cut;
76
+ cut = -1;
77
+ } else if (cut > start) cut = -1;
78
+ }
79
+ if (cut === chars.length && cut > start) {
80
+ sentences.push(chars.slice(start, cut).join(""));
81
+ start = cut;
82
+ }
83
+ const tail = chars.slice(start).join("");
84
+ if (tail.trim() !== "") sentences.push(tail);
85
+ return sentences;
86
+ }
87
+
88
+ export function sentenceLengths(text: string, within?: ReadonlySet<number>): readonly SentenceLength[] {
89
+ return scanMarkdown(text).flatMap((line) => {
90
+ if (line.kind !== "prose" || (within !== undefined && !within.has(line.line))) return [];
91
+ const kind: SentenceKind = orderedAt(line.raw) ? "procedural" : "descriptive";
92
+ return sentencesIn(bodyOf(line)).map((sentence) => ({ line: line.line, words: wordsIn(sentence), kind }));
93
+ });
94
+ }
95
+
96
+ export function longSentences(text: string, within?: ReadonlySet<number>): readonly LongSentence[] {
97
+ return sentenceLengths(text, within).flatMap(({ line, words, kind }) => {
98
+ const cap = kind === "procedural" ? PROCEDURAL_WORD_CAP : DESCRIPTIVE_WORD_CAP;
99
+ if (words <= cap) return [];
100
+ const rule =
101
+ kind === "procedural"
102
+ ? `procedural means an ordered list item; descriptive caps at ${DESCRIPTIVE_WORD_CAP} words`
103
+ : `procedural means an ordered list item, capped at ${PROCEDURAL_WORD_CAP} words`;
104
+ return [{ line, words, kind, message: `carries a ${words}-word ${kind} sentence, over the ${cap}-word cap (${rule})` }];
105
+ });
106
+ }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } from "effect";
3
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  import { runMain, Usage } from "../core/main.ts";
5
5
  import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
6
6
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Config, Effect, Schema } from "effect";
3
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { fullRunRefusal } from "./mutation-scope.js";
6
6
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
3
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  import { TEST_ENTRY_POINT } from "../core/gates.ts";
5
5
  import { runMain, Usage } from "../core/main.ts";
6
6
  import { readSkipDeclarations, type Environment, type SkipDeclaration, type TestTier } from "./test-skips.ts";