@avi2dg/checks 0.32.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.33.0
6
+
7
+ Released 2026-10-03.
8
+
9
+ ### Features
10
+
11
+ - **docs:** report sentences over trial length caps as advisory [#120](https://github.com/avi2d/checks/pull/120)
12
+
13
+ ### Fixes
14
+
15
+ - move effect to stable 4.0.0 [#122](https://github.com/avi2d/checks/pull/122)
16
+ - **complexity:** accept signed numbers and earlier props names in thin-astro defaults [#115](https://github.com/avi2d/checks/pull/115)
17
+ - **docs:** judge agent file entries and headings from a CommonMark parse [#116](https://github.com/avi2d/checks/pull/116)
18
+ - **docs:** fail link anchors the checks-docs reference rule could not check [#114](https://github.com/avi2d/checks/pull/114)
19
+
5
20
  ## 0.32.0
6
21
 
7
22
  Released 2026-09-29.
package/README.md CHANGED
@@ -9,12 +9,12 @@ Each repository owns its workflows and native tool configs, as [Native settings]
9
9
  <!-- generated prerequisites: bun run build writes it from package.json, .bun-version and scripts/doc-blocks.ts -->
10
10
 
11
11
  - A git repository, whose history the range gates read.
12
- - Bun 1.3.13, which runs every bin.
12
+ - Bun 1.4.2, which runs every bin.
13
13
  - The peer dependencies, at the exact versions the kit pins:
14
14
  - `@effect/tsgo` 0.45.0
15
15
  - `@swc/core` 1.16.2
16
16
  - `dependency-cruiser` 18.4.0
17
- - `effect` 4.0.0-rc.115
17
+ - `effect` 4.0.0
18
18
  - `jscpd` 5.3.2
19
19
  - `oxlint` 1.83.0
20
20
  - `oxlint-tsgolint` 7.0.2002
@@ -31,7 +31,7 @@ To consume the kit from a repository:
31
31
  <!-- generated install: bun run build writes it from package.json and scripts/doc-blocks.ts -->
32
32
 
33
33
  ```sh
34
- bun add -d @avi2dg/checks @effect/tsgo@0.45.0 @swc/core@1.16.2 dependency-cruiser@18.4.0 effect@4.0.0-rc.115 jscpd@5.3.2 oxlint@1.83.0 oxlint-tsgolint@7.0.2002 typescript@7.0.2
34
+ bun add -d @avi2dg/checks @effect/tsgo@0.45.0 @swc/core@1.16.2 dependency-cruiser@18.4.0 effect@4.0.0 jscpd@5.3.2 oxlint@1.83.0 oxlint-tsgolint@7.0.2002 typescript@7.0.2
35
35
  ```
36
36
 
37
37
  <!-- end generated install -->
@@ -400,11 +400,11 @@ var rule = {
400
400
  create(context) {
401
401
  const max = maxOf(context.options);
402
402
  const check = (node) => {
403
- const score2 = node.type === "StaticBlock" ? cognitiveComplexity(node, NO_NAMES) : cognitiveComplexity(node, selfNames(node));
404
- if (score2 <= max)
403
+ const score = node.type === "StaticBlock" ? cognitiveComplexity(node, NO_NAMES) : cognitiveComplexity(node, selfNames(node));
404
+ if (score <= max)
405
405
  return;
406
406
  const name = node.type === "StaticBlock" ? "static block" : `function \`${displayName(node)}\``;
407
- context.report({ node, message: `${name} has a cognitive complexity of ${score2}. Maximum allowed is ${max}.` });
407
+ context.report({ node, message: `${name} has a cognitive complexity of ${score}. Maximum allowed is ${max}.` });
408
408
  };
409
409
  return {
410
410
  FunctionDeclaration: check,
@@ -430,6 +430,9 @@ function isAstroProps(value) {
430
430
  const { object, property } = value;
431
431
  return object.type === "Identifier" && object.name === "Astro" && property.type === "Identifier" && property.name === "props";
432
432
  }
433
+ function isSignedNumber(value) {
434
+ return value.type === "UnaryExpression" && (value.operator === "-" || value.operator === "+") && value.argument.type === "Literal" && typeof value.argument.value === "number";
435
+ }
433
436
  function isPlainValue(value, bound) {
434
437
  return value.type === "Literal" || value.type === "Identifier" && bound.has(value.name);
435
438
  }
@@ -442,17 +445,23 @@ function readsProps(expression, bound) {
442
445
  return isAstroProps(value) || readsProps(value.object, bound);
443
446
  }
444
447
  function isPlainPattern(pattern, bound) {
448
+ return isPlainInOrder(pattern, bound, new Set(bound));
449
+ }
450
+ function isPlainInOrder(pattern, bound, seen) {
445
451
  if (pattern === null)
446
452
  return true;
447
453
  if (pattern.type === "RestElement")
448
- return isPlainPattern(pattern.argument, bound);
449
- if (pattern.type === "AssignmentPattern")
450
- return isPlainValue(pattern.right, bound) && isPlainPattern(pattern.left, bound);
454
+ return isPlainInOrder(pattern.argument, bound, seen);
455
+ if (pattern.type === "AssignmentPattern") {
456
+ return (isPlainValue(pattern.right, seen) || isSignedNumber(pattern.right)) && isPlainInOrder(pattern.left, bound, seen);
457
+ }
451
458
  if (pattern.type === "ArrayPattern")
452
- return pattern.elements.every((element) => isPlainPattern(element, bound));
453
- if (pattern.type === "Identifier")
459
+ return pattern.elements.every((element) => isPlainInOrder(element, bound, seen));
460
+ if (pattern.type === "Identifier") {
461
+ seen.add(pattern.name);
454
462
  return true;
455
- return pattern.properties.every((property) => property.type === "RestElement" ? isPlainPattern(property.argument, bound) : (!property.computed || isPlainValue(property.key, bound)) && isPlainPattern(property.value, bound));
463
+ }
464
+ return pattern.properties.every((property) => property.type === "RestElement" ? isPlainInOrder(property.argument, bound, seen) : (!property.computed || isPlainValue(property.key, bound)) && isPlainInOrder(property.value, bound, seen));
456
465
  }
457
466
  function isPropsRead(declarator, bound) {
458
467
  return declarator.init !== null && readsProps(declarator.init, bound) && isPlainPattern(declarator.id, bound);
@@ -48,7 +48,7 @@ The base loads the kit's `data-shape` plugin from `dist/` with one rule for ever
48
48
  An override in `oxlintrc.json` turns on one rule of the kit's `readability` plugin in each `.astro` file:
49
49
 
50
50
  - `readability/thin-astro` refuses a statement in the frontmatter or a script block that is neither an import, a re-export from another module, a type or interface declaration, nor a variable read from `Astro.props`.
51
- - A default inside an `Astro.props` destructuring passes only when it is a literal or a name read from `Astro.props` earlier, so `const { title = "Home" } = Astro.props;` passes and a call or `await` in a default is refused.
51
+ - A default inside an `Astro.props` destructuring passes only when it is a literal, a `-` or `+` on a numeric literal, or a name read from `Astro.props` earlier, in the same pattern or a statement before it, so `const { title = "Home", heading = title } = Astro.props;` passes and a call or `await` in a default is refused.
52
52
  - Move a refused statement into a `.ts` file and import it, so the `.astro` file holds only imports, props and markup.
53
53
  - A script block loads client code with a side-effect import, as in `<script>import "../client.ts";</script>`, and the override turns off `import/no-unassigned-import` so that import passes.
54
54
  - A dynamic route re-exports `getStaticPaths` from a `.ts` file, as in `export { getStaticPaths } from "../lib/paths.ts";`.
package/docs/design.md CHANGED
@@ -67,7 +67,7 @@ So the fragment lists the rules in `files`, and in `include` beside every file u
67
67
 
68
68
  The source sits under `src/<vector>/`, one directory for each thing the kit judges a repository on: complexity, quality, testing, docs, delivery and dependencies.
69
69
  `src/core/` holds what every vector runs on.
70
- `scripts/` holds only the kit's own build, and nothing in it ships.
70
+ `scripts/` holds only the kit's own build and CI tooling, and nothing in it ships.
71
71
  Sorting files by what loads them would put both oxlint plugins at the root and every bin in one flat directory.
72
72
  Nothing would then say which gate a helper serves.
73
73
  A mutation runner's default scope covers `src/`, so the kit's own Stryker run mutates its source with no `mutate` list.
@@ -100,7 +100,7 @@ The `.ts` bins keep a `bun` shebang and need no build step, unlike the oxlint pl
100
100
  The bins are written in Effect.
101
101
  So `effect` is a peer dependency, and `@effect/platform-bun`, which only the bins use, is a dependency.
102
102
  `@effect/platform-node-shared` is a direct dependency only to pin its version.
103
- `@effect/platform-bun` asks for it with a `^` range, and a newer release candidate of it peers on a newer `effect` than consumers install.
103
+ `@effect/platform-bun` asks for it with a `^` range, so a newer minor of it could resolve and peer on a newer `effect` than the exact version consumers install.
104
104
  So the three packages move together at one exact version.
105
105
 
106
106
  ## checks-lint runs each gate as its own bin
@@ -211,6 +211,10 @@ A score cannot fail a change without failing correct prose, and a suggestion tha
211
211
  `src/docs/prose-matchers.ts` imports nothing, so the gate and a write-time hook run one matcher and refuse in the same words.
212
212
  A hook bundle ships without `node_modules`, so a matcher that needed Vale or another package could not refuse at write time.
213
213
 
214
+ The entry rule and the rule against a `## Maintaining this file` section take their list items and headings from `commonmark`, the CommonMark reference parser.
215
+ A reader sees the blocks a renderer builds, and a line rule that guesses at blockquotes, HTML blocks and indented code misjudges each corner its guess misses.
216
+ Only the gate runs these rules, so the parser costs no hook anything, and the line scan reads each entry's code spans and links from that entry's line alone.
217
+
214
218
  A path, link or command on a line the range leaves alone still fails when the range broke it, for example by deleting the file it names.
215
219
  A reference goes stale far more often because the code it names moves than because its own line is edited.
216
220
  So a gate on edited lines alone would miss the usual break.
@@ -110,6 +110,8 @@ A line holds one sentence, so a changed line is a changed sentence.
110
110
  A bold label that opens a line, as in `**Status.**`, heads the sentence after it and is not a sentence of its own.
111
111
  No rule reads fenced code, inline code, link destinations, URLs, HTML comments or front matter.
112
112
  Readability scores and word choice, such as easy, are not checked.
113
+ A sentence over the trial length cap is listed as advisory and never fails the run.
114
+ The cap is 20 words in an ordered list item and 25 words elsewhere.
113
115
 
114
116
  `src/docs/prose-matchers.ts` holds the rules and a synchronous `proseRefused()`, and imports nothing.
115
117
  The package exports it as `@avi2dg/checks/scripts/prose-matchers.ts`.
@@ -121,7 +123,8 @@ Each reference a living doc or an agent file names has to resolve at the head co
121
123
 
122
124
  - A path in inline code that ends in a file extension, such as `src/core/lint.ts`, names a file from the root or from the doc's directory.
123
125
  - A relative Markdown link names a file or a directory.
124
- Its anchor names a heading in that file, as GitHub derives the anchor, or an explicit `id`.
126
+ Its anchor names a heading in that Markdown or MDX file, as GitHub derives the anchor, or an explicit `id`.
127
+ An anchor into a directory or any other file fails, because it names no heading the gate can check.
125
128
  - A `bun run` command in code names a script in the nearest `package.json`, a bin in `node_modules/.bin`, or a file that exists.
126
129
  - A code span fails when some tracked file outside the docs held that exact text at the base, and none holds it at the head.
127
130
  A span the path check already fails on is not reported again.
@@ -150,7 +153,8 @@ audience: consumers
150
153
  An agent file holds the router its template sketches, and the rules below hold its shape whatever the range touches.
151
154
  A file over 3,000 characters fails.
152
155
  Move each part's notes into the people doc that covers that part, and delete what a check or the code already holds.
153
- A file that holds a `## Maintaining this file` section fails, because this gate holds the shape the section asked for.
156
+ A file that holds a `## Maintaining this file` section a reader sees fails, because this gate holds the shape the section asked for.
157
+ A heading inside an HTML comment, an HTML block or a code block is not such a section.
154
158
  Each entry names at least one of these, or it fails:
155
159
 
156
160
  - A path in inline code that git tracks at the head commit, a file or a directory, such as `package.json`, `LICENSE` or `.gitignore`.
@@ -159,7 +163,7 @@ Each entry names at least one of these, or it fails:
159
163
  - A `bun run` command.
160
164
 
161
165
  Whether the link or the command resolves is the reference rule's call, as [Paths, links and commands](#paths-links-and-commands) says, and it fails when the range adds or breaks one.
162
- An entry is any list item a reader sees, the items above the first section included.
166
+ An entry is any list item a reader sees as CommonMark renders the file, the items above the first section included.
163
167
  A list item inside an HTML comment, an HTML block or an indented code block is not an entry.
164
168
  A fresh file passes the ceiling, and its entries pass once each names the file that holds its detail.
165
169
 
@@ -205,6 +209,8 @@ docs: 6 violation(s):
205
209
  docs/parts.md:9: names `gates.lint`, which the range removed from every file outside the docs. Say what holds now, or drop the line
206
210
  docs: advisory, 1 doc file(s) the range leaves alone do not hold to their templates yet:
207
211
  docs/adr/0001-quality-gates.md: 5 violation(s)
212
+ docs: advisory, 1 sentence(s) over the trial length caps:
213
+ README.md:16: carries a 27-word descriptive sentence, over the 25-word cap (procedural means an ordered list item, capped at 20 words)
208
214
  docs: advisory, 1 path(s), link(s) or command(s) the living docs or agent files name were broken before the range:
209
215
  docs/parts.md:9: links to `suppliers.md#prices`, and `docs/suppliers.md` has no heading with that anchor
210
216
  ```
@@ -59,8 +59,9 @@ An `--incremental` run with no report stops before instrumenting with the missin
59
59
  ## When it runs
60
60
 
61
61
  A repository runs full baselines from the mutation workflow on `workflow_dispatch`.
62
+ A repository that picks a pull request scope from a baseline also runs that workflow on a nightly schedule.
62
63
  Run scoped checks locally during development.
63
- A scheduled run never starts one, because a baseline costs a full Stryker run.
64
+ A scheduled run costs a full Stryker run.
64
65
 
65
66
  ## Running it in CI
66
67
 
@@ -60,19 +60,19 @@ With no arguments it pins nothing.
60
60
  ## Sample output
61
61
 
62
62
  ```
63
- checks-vendor: cloned effect@4.0.0-rc.115 from https://github.com/Effect-TS/effect.git and linked repos/effect
63
+ checks-vendor: cloned effect@4.0.0 from https://github.com/Effect-TS/effect.git and linked repos/effect
64
64
  ```
65
65
 
66
66
  A fresh fetch reports the tag it cloned and the link it made.
67
67
 
68
68
  ```
69
- checks-vendor: repos/effect still holds effect@4.0.0-rc.115, verified against its recorded commit
69
+ checks-vendor: repos/effect still holds effect@4.0.0, verified against its recorded commit
70
70
  ```
71
71
 
72
72
  A later run reports the link it kept.
73
73
 
74
74
  ```
75
- checks-vendor: found 1 path writable by its owner, starting with /home/runner/.cache/avi2dg-checks/repos/github.com/Effect-TS/effect/effect@4.0.0-rc.115, and froze the tree again, so repos/effect still holds effect@4.0.0-rc.115, verified against its recorded commit
75
+ checks-vendor: found 1 path writable by its owner, starting with /home/runner/.cache/avi2dg-checks/repos/github.com/Effect-TS/effect/effect@4.0.0, and froze the tree again, so repos/effect still holds effect@4.0.0, verified against its recorded commit
76
76
  ```
77
77
 
78
78
  A run that found an owner write bit reports how many paths carried one and the first of them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -60,6 +60,7 @@
60
60
  "src/complexity/exports.ts",
61
61
  "src/complexity/knip.ts",
62
62
  "src/testing/quarantine-clock.ts",
63
+ "src/docs/sentence-length.ts",
63
64
  "src/docs/docs.ts",
64
65
  "src/dependencies/vendor.ts",
65
66
  "src/dependencies/vendor-args.ts",
@@ -136,7 +137,7 @@
136
137
  "@swc/core": "1.16.2",
137
138
  "dependency-cruiser": "18.4.0",
138
139
  "@effect/tsgo": "0.45.0",
139
- "effect": "4.0.0-rc.115",
140
+ "effect": "4.0.0",
140
141
  "jscpd": "5.3.2",
141
142
  "oxlint": "1.83.0",
142
143
  "oxlint-tsgolint": "7.0.2002",
@@ -149,8 +150,9 @@
149
150
  "@stryker-mutator/core": "10.0.0",
150
151
  "@swc/core": "1.16.2",
151
152
  "@types/bun": "^1.3.0",
153
+ "@types/commonmark": "0.27.10",
152
154
  "dependency-cruiser": "18.4.0",
153
- "effect": "4.0.0-rc.115",
155
+ "effect": "4.0.0",
154
156
  "jscpd": "5.3.2",
155
157
  "oxlint": "1.83.0",
156
158
  "oxlint-tsgolint": "7.0.2002",
@@ -159,13 +161,17 @@
159
161
  "dependencies": {
160
162
  "@commitlint/cli": "21.2.3",
161
163
  "@commitlint/config-conventional": "21.2.3",
162
- "@effect/platform-bun": "4.0.0-rc.115",
163
- "@effect/platform-node-shared": "4.0.0-rc.115",
164
+ "@effect/platform-bun": "4.0.0",
165
+ "@effect/platform-node-shared": "4.0.0",
164
166
  "@total-typescript/ts-reset": "0.6.1",
167
+ "commonmark": "0.31.2",
165
168
  "knip": "6.38.0"
166
169
  },
167
170
  "overrides": {
168
171
  "qs": "6.16.0",
169
172
  "smol-toml": "1.9.0"
173
+ },
174
+ "patchedDependencies": {
175
+ "@hughescr/stryker-bun-runner@1.4.0": "patches/@hughescr%2Fstryker-bun-runner@1.4.0.patch"
170
176
  }
171
177
  }
package/src/core/git.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Config, Effect, FileSystem, Path, Schema, Stream } from "effect";
2
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
3
  import { DEFAULT_BRANCH } from "./gates.ts";
4
4
  import { Usage } from "./main.ts";
5
5
 
package/src/core/lint.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  import { EVERY_REPOSITORY, KIT_GATES, type KitGate } from "./gates.ts";
5
5
  import { defaultBranch, git } from "./git.ts";
6
6
  import { runMain, Usage } from "./main.ts";
@@ -1,5 +1,5 @@
1
1
  import { Effect, Schema } from "effect";
2
- import { ChildProcessSpawner } from "effect/unstable/process";
2
+ import { ChildProcessSpawner } from "effect/process";
3
3
  import { collect } from "../core/git.ts";
4
4
 
5
5
  export class GitHubFailure extends Schema.TaggedError<GitHubFailure>()("GitHubFailure", {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, Encoding, FileSystem, Path, Schema } from "effect";
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import * as Base64 from "effect/encoding/Base64";
3
4
  import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
4
5
  import { nextVersion, type Pending, readPending, releaseTitle, runBuild, withVersion } from "./release.ts";
5
6
  import { git } from "../core/git.ts";
@@ -105,7 +106,7 @@ const readStaged = Effect.fn("readStaged")(function* (root: string, staged: Stag
105
106
  if (staged.kind === "deleted") return staged;
106
107
  if (!REGULAR_FILE_MODES.has(staged.mode)) return yield* refused(`the build wrote ${staged.path} with mode ${staged.mode}, which is no regular file`);
107
108
  const bytes = yield* (yield* FileSystem.FileSystem).readFile((yield* Path.Path).join(root, staged.path));
108
- return { ...staged, content: Encoding.encodeBase64(bytes) };
109
+ return { ...staged, content: Base64.encode(bytes) };
109
110
  });
110
111
 
111
112
  // The build writes into the working tree, so the tree it starts from has to hold nothing a release would sweep in.
@@ -1,5 +1,5 @@
1
1
  import { Effect, Schema } from "effect";
2
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
3
  import { groupOf } from "./changelog.ts";
4
4
  import { git } from "../core/git.ts";
5
5
 
@@ -1,6 +1,7 @@
1
- import { Crypto, Effect, Encoding, FileSystem, Path, Schema } from "effect";
2
- import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3
- import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
1
+ import { Crypto, Effect, FileSystem, Path, Schema } from "effect";
2
+ import * as Hex from "effect/encoding/Hex";
3
+ import { HttpClient, HttpClientResponse } from "effect/http";
4
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
4
5
  import { collect } from "../core/git.ts";
5
6
 
6
7
  const EXECUTABLE_MODE = 0o755;
@@ -19,7 +20,7 @@ function installedSha256(asset: Asset): string {
19
20
 
20
21
  const sha256Of = Effect.fn("sha256Of")(function* (bytes: Uint8Array) {
21
22
  const crypto = yield* Crypto.Crypto;
22
- return Encoding.encodeHex(yield* crypto.digest("SHA-256", bytes));
23
+ return Hex.encode(yield* crypto.digest("SHA-256", bytes));
23
24
  });
24
25
 
25
26
  const verified = Effect.fn("verified")(function* (binary: string, sha256: string) {
@@ -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";