@avi2dg/checks 0.73.0 → 0.74.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,14 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.74.0
6
+
7
+ Released 2026-11-07.
8
+
9
+ ### Features
10
+
11
+ - **quality:** refuse folder-level import cycles in the dependency-cruiser preset [#69](https://github.com/avi2d/checks/pull/69)
12
+
5
13
  ## 0.73.0
6
14
 
7
15
  Released 2026-11-07.
@@ -9,6 +9,16 @@ export default {
9
9
  "circular": true
10
10
  }
11
11
  },
12
+ {
13
+ "name": "no-folder-cycle",
14
+ "severity": "error",
15
+ "comment": "Folders reach each other in a cycle, so none stands without the others. Move the module that reaches back into the folder it imports, or move what the folders need into a third folder they all import.",
16
+ "scope": "folder",
17
+ "from": {},
18
+ "to": {
19
+ "circular": true
20
+ }
21
+ },
12
22
  {
13
23
  "name": "no-orphans",
14
24
  "severity": "error",
@@ -13,6 +13,14 @@ var BASE = {
13
13
  from: {},
14
14
  to: { circular: true }
15
15
  },
16
+ {
17
+ name: "no-folder-cycle",
18
+ severity: "error",
19
+ comment: "Folders reach each other in a cycle, so none stands without the others. Move the module that reaches back into the folder it imports, or move what the folders need into a third folder they all import.",
20
+ scope: "folder",
21
+ from: {},
22
+ to: { circular: true }
23
+ },
16
24
  {
17
25
  name: "no-orphans",
18
26
  severity: "error",
@@ -11,6 +11,7 @@ The kit's dependency-cruiser rules hold a repository's imports, and a repository
11
11
  `defineConfig` from `@avi2dg/checks/dependency-cruiser` returns these rules, with or without a config of the repository's own:
12
12
 
13
13
  - `no-circular`
14
+ - `no-folder-cycle`, which refuses folders that reach each other in a cycle even when no module cycle joins them
14
15
  - `no-orphans`, which a repository that depends on `astro` gets only by listing its own, as [checks-graphwalk](../gates/checks-graphwalk.md) explains
15
16
  - `not-to-dev-dep`, which refuses shipped source importing a dev-only package, and a package listed in `peerDependencies` too is not dev-only
16
17
  - `no-non-package-json`, which refuses an import of an installed package that the nearest `package.json` does not declare, and counts a peer that `peerDependenciesMeta` marks optional as declared
@@ -18,6 +19,8 @@ The kit's dependency-cruiser rules hold a repository's imports, and a repository
18
19
  - `no-deep-imports`, which refuses a subpath the package's exports map does not publish
19
20
 
20
21
  A test file, a config file and every path under `tests/` may import a dev-only package.
22
+ Shipped source may import a dev-only package with `import type` or an inline `type` specifier, since swc tags that import type-only without a TypeScript compiler.
23
+ Both cycle rules count a type-only import as an edge.
21
24
 
22
25
  The rules parse with swc, so the cruise needs `@swc/core` installed, and without it the cruise silently skips every `.ts` file.
23
26
 
package/docs/design.md CHANGED
@@ -78,6 +78,7 @@ So the fragment lists the rules in `files`, and in `include` beside every file u
78
78
 
79
79
  The source sits under `src/<vector>/`, one directory for each thing the kit judges a repository on: complexity, quality, testing, docs, delivery and dependencies.
80
80
  `src/core/` holds what every vector runs on.
81
+ An area imports `src/core/` and only the other areas listed for it in `AREA_IMPORTS` in the repository's `dependency-cruiser.config.ts`, so code two areas share moves into `src/core/` rather than one area importing the other back.
81
82
  `scripts/` holds only the kit's own build and CI tooling, and nothing in it ships.
82
83
  Sorting files by what loads them would put both oxlint plugins at the root and every bin in one flat directory.
83
84
  Nothing would then say which gate a helper serves.
@@ -96,15 +97,18 @@ A path read without the resolver, such as `node_modules/@avi2dg/checks/scripts/t
96
97
 
97
98
  ## The package ships built plugins and runnable bins
98
99
 
99
- `files` in `package.json` lists what an install gets.
100
- `tests/`, `AGENTS.md` and the TypeScript source of the oxlint plugins never reach an install.
100
+ `files` in `package.json` lists what an install gets, and `tests/` and `AGENTS.md` never reach an install.
101
+ It ships `docs/`, `src/` and `dist/`, so a new bin or page ships with no edit to `files`.
102
+ Its `!` entries keep out the decision records under `docs/adr/` and the sources only the build reads.
103
+ A source a shipped bin imports ships even when the build also compiles it.
101
104
  npm adds `package.json`, `README.md` and `LICENSE` whatever `files` says.
102
105
  `bun pm pack` builds the same tarball the registry serves, and the consumer e2e test installs that tarball.
103
106
 
104
107
  Each oxlint plugin and each config builder ships compiled under `dist/`, because Node refuses to strip types from a `.ts` file under `node_modules`.
105
108
  Stryker loads its config under Node too, so the guard plugin and run-scope helpers the Stryker preset imports ship compiled under `dist/mutation/`.
106
109
  oxlint loads `oxlint.config.ts` under Node, so the builders ship as JavaScript beside a `.d.ts` that `tsc` reads.
107
- `@oxlint/plugins` ships no RuleTester, so each `effect-channel`, `readability` and `data-shape` rule is proven red and green against an installed consumer in `tests/e2e/consumer.test.ts`, except `readability/thin-astro`, which `tests/e2e/astro-consumer.test.ts` proves against a consumer tree linked to the checkout.
110
+ `@oxlint/plugins` ships no RuleTester, so each `effect-channel`, `readability` and `data-shape` rule is proven red and green against an installed consumer in `tests/e2e/package/consumer.test.ts`, except `readability/thin-astro`.
111
+ `readability/thin-astro` is proven against a consumer tree linked to the checkout in `tests/e2e/complexity/astro-consumer.test.ts`.
108
112
  `dist/` is committed, with the doc templates in `dist/templates/`, and so is `CHANGELOG.md`, which the same build writes.
109
113
  No `prepack` or `prepublishOnly` script rebuilds them, so a publish ships the committed files.
110
114
  CI runs `git diff --exit-code` over the whole tree after `bun run build`.
@@ -17,7 +17,7 @@ A configuration that turns the files rule off still has its files judged.
17
17
  It holds each reported symbol against `exports-baseline.json`, and fails on any symbol the baseline does not hold.
18
18
  It fails on any baseline entry the range adds unless Knip reports that same symbol unused at the base, so the baseline records debt that already existed and never debt the range creates.
19
19
  It fails on any baseline entry Knip no longer reports, so the baseline only shrinks.
20
- It runs dependency-cruiser over every tracked `.ts`, `.tsx`, `.mts` and `.cts` file and names each violation with its rule, the importing module and the module it reaches.
20
+ It runs dependency-cruiser over every tracked `.ts`, `.tsx`, `.mts` and `.cts` file and names each violation with its rule, the importing module or folder and the module or folder it reaches.
21
21
  It fails on a violation whose rule has `error` severity, and lists a `warn` or `info` violation without failing.
22
22
  It fails when the repository tracks no `.ts`, `.tsx`, `.astro`, `.mts` or `.cts` file, since an empty scan would pass without judging anything.
23
23
  It fails when the repository holds no Knip configuration, since Knip's default entries cannot tell a dead file from an entry point.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.73.0",
3
+ "version": "0.74.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -17,127 +17,26 @@
17
17
  "type": "module",
18
18
  "files": [
19
19
  "CHANGELOG.md",
20
- "docs/bins.md",
21
- "docs/configs/",
22
- "docs/design.md",
23
- "docs/gates/",
24
- "docs/how-to/",
25
- "docs/shipped-files.md",
20
+ "docs/",
21
+ "!docs/adr/",
26
22
  "bunfig.toml",
27
23
  ".bun-version",
28
24
  "commitlint.config.ts",
29
25
  "dependency-cruiser.config.js",
30
26
  "knip-base.json",
31
- "src/quality/lint-coverage.sh",
32
- "src/core/main.ts",
33
- "src/core/git.ts",
34
- "src/core/gates.ts",
35
- "src/core/declared-inputs.ts",
36
- "src/core/lint.ts",
37
- "src/core/scope.ts",
38
- "src/delivery/changelog.ts",
39
- "src/delivery/changelog-write.ts",
40
- "src/delivery/release-notes.ts",
41
- "src/delivery/release-report.ts",
42
- "src/delivery/release.ts",
43
- "src/delivery/release-commit.ts",
44
- "src/delivery/release-pr.ts",
45
- "src/delivery/release-preflight.ts",
46
- "src/delivery/release-schedule.ts",
47
- "src/delivery/schedule-policy.ts",
48
- "src/delivery/release-publish.ts",
49
- "src/delivery/release-push.ts",
50
- "src/delivery/release-tag.ts",
51
- "src/delivery/release-tagging.ts",
52
- "src/delivery/github.ts",
53
- "src/testing/test-layout.ts",
54
- "src/testing/block-screens.ts",
55
- "src/testing/test.ts",
56
- "src/testing/test-skips.ts",
57
- "src/testing/affected-tests.ts",
58
- "src/testing/changed-tests.ts",
59
- "src/core/swc.ts",
60
- "src/testing/test-report.ts",
61
- "src/testing/flake.ts",
62
- "src/testing/flake-preload.ts",
63
- "src/delivery/commit-identity.ts",
64
- "src/delivery/commit-title.ts",
65
- "src/delivery/docs-only.ts",
66
- "src/testing/mutation.ts",
67
- "src/testing/mutation-refusal.ts",
68
- "src/testing/mutation-compare.ts",
69
- "src/testing/mutation-scope.ts",
70
- "src/testing/mutation-scope-graph.ts",
71
- "src/testing/mutation-baseline.ts",
72
- "src/testing/mutation-baseline-full.ts",
73
- "src/testing/mutation-baseline-saved.ts",
74
- "src/testing/subsumed-tests.ts",
75
- "src/testing/whole-tree-run.ts",
76
- "src/testing/whole-tree-run-findings.ts",
77
- "src/delivery/ci-wiring.ts",
78
- "src/delivery/shell-command.ts",
79
- "src/delivery/workflow-references.ts",
80
- "src/delivery/bun-pin.ts",
81
- "src/delivery/mutation-job.ts",
82
- "src/delivery/job-timeout.ts",
83
- "src/delivery/runner-policy.ts",
84
- "src/delivery/runner-pick.ts",
85
- "src/delivery/secrets.ts",
86
- "src/delivery/secrets-pack.ts",
87
- "src/delivery/pr-text.ts",
88
- "src/delivery/setup.ts",
89
- "src/delivery/setup-workflow.ts",
90
- "src/delivery/gitleaks.ts",
91
- "src/delivery/gitleaks.toml",
92
- "src/quality/comment-matchers.ts",
93
- "src/docs/prose-matchers.ts",
94
- "src/quality/comments.ts",
95
- "src/quality/comment-gate.ts",
96
- "src/quality/css-size.ts",
97
- "src/quality/frontend-syntax.ts",
98
- "src/quality/browser/",
99
- "src/complexity/suppressions-ratchet.ts",
100
- "src/complexity/repetition.ts",
101
- "src/complexity/graphwalk.ts",
102
- "src/complexity/knip.ts",
103
- "src/testing/quarantine-clock.ts",
104
- "src/docs/sentence-length.ts",
105
- "src/docs/docs.ts",
106
- "src/docs/copy.ts",
107
- "src/docs/copy-text.ts",
108
- "src/docs/copy-html.ts",
109
- "src/dependencies/vendor.ts",
110
- "src/dependencies/vendor-args.ts",
111
- "src/dependencies/cache-root.ts",
112
- "src/dependencies/advisories.ts",
113
- "src/dependencies/advisory-rules.ts",
114
- "src/dependencies/osv-scanner.ts",
115
- "src/dependencies/pinned-binary.ts",
116
- "src/docs/doc-agents.ts",
117
- "src/docs/doc-outline.ts",
118
- "src/docs/doc-rules.ts",
119
- "src/docs/doc-references.ts",
120
- "src/docs/doc-snapshot.ts",
121
- "src/docs/doc-templates.ts",
122
- "src/docs/doc-names.ts",
123
- "src/docs/doc-versions.ts",
124
- "dist/templates/",
125
- "src/quality/presets/effect.language-service.json",
126
- "src/quality/presets/oxlint.ts",
127
- "src/quality/presets/dependency-cruiser.ts",
128
- "src/quality/presets/stylelint.ts",
129
- "src/quality/effect-scope.ts",
130
- "src/quality/jsonc-patch.ts",
131
- "src/dependencies/kit-defaults.ts",
27
+ "src/",
28
+ "!src/complexity/readability/",
29
+ "!src/quality/data-shape/",
30
+ "!src/quality/effect-channel/",
31
+ "!src/quality/presets/knip.ts",
32
+ "!src/quality/presets/postcss-value-parser.d.ts",
33
+ "!src/testing/mutation-guard-plugin.ts",
34
+ "!src/testing/unit-test-order.ts",
35
+ "dist/",
132
36
  "oxlintrc.json",
133
37
  "stryker.preset.js",
134
38
  "tsconfig.effect.json",
135
- "ts-reset.d.ts",
136
- "dist/effect-channel/index.js",
137
- "dist/readability/index.js",
138
- "dist/data-shape/index.js",
139
- "dist/mutation/",
140
- "dist/presets/"
39
+ "ts-reset.d.ts"
141
40
  ],
142
41
  "exports": {
143
42
  "./dependency-cruiser.config.js": "./dependency-cruiser.config.js",
@@ -1,6 +1,6 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { ChildProcessSpawner } from "effect/process";
3
- import { collect, type Feed } from "../core/git.ts";
3
+ import { collect, type Feed } from "./git.ts";
4
4
 
5
5
  export class GitHubFailure extends Schema.TaggedError<GitHubFailure>()("GitHubFailure", {
6
6
  message: Schema.String,
@@ -0,0 +1,14 @@
1
+ export const MANIFEST = "package.json";
2
+
3
+ export const TAG_PREFIX = "v";
4
+
5
+ export const RELEASE_TAG_GLOB = `${TAG_PREFIX}[0-9]*.[0-9]*.[0-9]*`;
6
+
7
+ export function releaseTag(version: string): string {
8
+ return `${TAG_PREFIX}${version}`;
9
+ }
10
+
11
+ // The tag starts with TAG_PREFIX.
12
+ export function taggedVersion(tag: string): string {
13
+ return tag.slice(TAG_PREFIX.length);
14
+ }
@@ -0,0 +1,45 @@
1
+ import { TEST_ENTRY_POINT } from "./gates.ts";
2
+
3
+ export type TestTier = "live" | "pixel" | "unit" | "e2e";
4
+
5
+ export type TestOptions = {
6
+ readonly tier?: TestTier;
7
+ readonly workers?: number;
8
+ readonly full?: boolean;
9
+ };
10
+
11
+ export const FULL = "--full";
12
+ const TIERS = new Map<string, TestTier>([
13
+ ["--tier=live", "live"],
14
+ ["--tier=pixel", "pixel"],
15
+ ["--tier=unit", "unit"],
16
+ ["--tier=e2e", "e2e"],
17
+ ]);
18
+ const PARALLEL = /^--parallel=([1-9][0-9]*)$/;
19
+
20
+ function optionOf(arg: string): TestOptions | undefined {
21
+ const tier = TIERS.get(arg);
22
+ if (tier !== undefined) return { tier };
23
+ if (arg === FULL) return { full: true };
24
+ const workers = PARALLEL.exec(arg)?.[1];
25
+ return workers === undefined ? undefined : { workers: Number(workers) };
26
+ }
27
+
28
+ export function testOptions(args: readonly string[]): TestOptions | undefined {
29
+ let options: TestOptions = {};
30
+ for (const arg of args) {
31
+ const option = optionOf(arg);
32
+ if (option === undefined || Object.keys(option).some((key) => key in options)) return undefined;
33
+ options = { ...options, ...option };
34
+ }
35
+ if (options.tier !== undefined && options.full === true) return undefined;
36
+ return options;
37
+ }
38
+
39
+ const TEST_SCRIPTS: readonly string[] = [TEST_ENTRY_POINT.bin, `bun src/${TEST_ENTRY_POINT.script}`];
40
+
41
+ export function scriptOptions(script: unknown): TestOptions | undefined {
42
+ if (typeof script !== "string") return undefined;
43
+ const entry = TEST_SCRIPTS.find((candidate) => script === candidate || script.startsWith(`${candidate} `));
44
+ return entry === undefined ? undefined : testOptions(script.slice(entry.length).split(" ").filter((arg) => arg !== ""));
45
+ }
@@ -2,13 +2,12 @@
2
2
  import { Console, DateTime, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { cuts, hasEntries, namesPullRequest, releaseDates, renderChangelog, type Bump, type Cut, type Release } from "./changelog.ts";
4
4
  import { git } from "../core/git.ts";
5
+ import { MANIFEST, TAG_PREFIX, taggedVersion } from "../core/release-names.ts";
5
6
  import { runMain } from "../core/main.ts";
6
7
 
7
8
  const NAME = "checks-changelog";
8
9
  const CHANGELOG = "CHANGELOG.md";
9
- const MANIFEST = "package.json";
10
10
  const FIELD = "\x1f";
11
- const TAG_PREFIX = "v";
12
11
 
13
12
  class ChangelogUnreadable extends Schema.TaggedError<ChangelogUnreadable>()("ChangelogUnreadable", {
14
13
  message: Schema.String,
@@ -75,7 +74,7 @@ const readPublishedShas = Effect.fn("readPublishedShas")(function* (root: string
75
74
  const tags = yield* git(["for-each-ref", "--format=%(refname:strip=2)", `refs/tags/${TAG_PREFIX}*`], root);
76
75
  const published = new Set<string>();
77
76
  for (const tag of tags.split("\n").filter((line) => line.startsWith(TAG_PREFIX))) {
78
- const version = tag.slice(TAG_PREFIX.length);
77
+ const version = taggedVersion(tag);
79
78
  for (const bump of bumps.filter((one) => one.version === version).toReversed()) {
80
79
  if (yield* reaches(root, bump.sha, `refs/tags/${tag}`)) {
81
80
  published.add(bump.sha);
@@ -3,7 +3,7 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { runMain, Usage } from "../core/main.ts";
4
4
  import { cacheRoot } from "../dependencies/cache-root.ts";
5
5
  import { pinnedGitleaks, scanDirectory, type Leak } from "./gitleaks.ts";
6
- import { gh, gitHubJson, GitHubFailure, REPOSITORY } from "./github.ts";
6
+ import { gh, gitHubJson, GitHubFailure, REPOSITORY } from "../core/github.ts";
7
7
  import { scanConfig } from "./secrets-pack.ts";
8
8
 
9
9
  const NAME = "pr-text";
@@ -1,8 +1,9 @@
1
1
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
2
  import * as Base64 from "effect/encoding/Base64";
3
- import { gitHubJson, REPOSITORY } from "./github.ts";
3
+ import { gitHubJson, REPOSITORY } from "../core/github.ts";
4
4
  import { nextVersion, type Pending, readPending, releaseTitle, runBuild, withVersion } from "./release.ts";
5
5
  import { git } from "../core/git.ts";
6
+ import { MANIFEST, releaseTag, taggedVersion } from "../core/release-names.ts";
6
7
 
7
8
  export class ReleaseRefused extends Schema.TaggedError<ReleaseRefused>()("ReleaseRefused", {
8
9
  message: Schema.String,
@@ -21,7 +22,6 @@ type Built = {
21
22
  readonly files: readonly Read[];
22
23
  };
23
24
 
24
- export const MANIFEST = "package.json";
25
25
  const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
26
26
  const RAW_ENTRY = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])/;
27
27
 
@@ -56,9 +56,9 @@ export const versionIn = (manifest: string): Effect.Effect<string | undefined> =
56
56
 
57
57
  const releaseVersion = Effect.fn("releaseVersion")(function* (manifest: string, { since, unreleased }: Pending, untagged: string) {
58
58
  const { version } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => refused(`${MANIFEST}: ${cause.message}`)));
59
- if (since !== undefined && since !== `v${version}`) {
59
+ if (since !== undefined && since !== releaseTag(version)) {
60
60
  return yield* refused(
61
- `${MANIFEST} holds ${version}, but the last release tag is ${since}, so v${version} is untagged; ${untagged}, or return its version to ${since.slice(1)}`,
61
+ `${MANIFEST} holds ${version}, but the last release tag is ${since}, so ${releaseTag(version)} is untagged; ${untagged}, or return its version to ${taggedVersion(since)}`,
62
62
  );
63
63
  }
64
64
  const next = nextVersion(version, unreleased);
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Schema } from "effect";
3
- import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
3
+ import { dispatch, gitHubJson, REPOSITORY } from "../core/github.ts";
4
4
  import { releaseTitle, runningWorkflow } from "./release.ts";
5
- import { buildRelease, commitRelease, hasBranch, MANIFEST, planRelease, pointBranch, versionIn } from "./release-commit.ts";
5
+ import { buildRelease, commitRelease, hasBranch, planRelease, pointBranch, versionIn } from "./release-commit.ts";
6
6
  import { git } from "../core/git.ts";
7
+ import { MANIFEST } from "../core/release-names.ts";
7
8
  import { runMain, Usage } from "../core/main.ts";
8
9
 
9
10
  type Remote = {
@@ -4,11 +4,11 @@ import { lastTag } from "./release.ts";
4
4
  import { refused } from "./release-commit.ts";
5
5
  import { fromProgram, shellWords, type Command } from "./shell-command.ts";
6
6
  import { git } from "../core/git.ts";
7
+ import { MANIFEST, releaseTag, taggedVersion } from "../core/release-names.ts";
7
8
  import { runMain, Usage } from "../core/main.ts";
8
9
 
9
10
  const LABEL = "release-preflight";
10
11
  const USAGE = "usage: release-preflight.ts <release-workflow>";
11
- const MANIFEST = "package.json";
12
12
  const WORKFLOWS = ".github/workflows";
13
13
 
14
14
  const Manifest = Schema.Struct({
@@ -114,9 +114,9 @@ const checkTag = Effect.fn("checkTag")(function* (root: string) {
114
114
  }
115
115
  const { version } = yield* readManifest(root);
116
116
  const since = yield* lastTag(root);
117
- if (since !== undefined && since !== `v${version}`) {
117
+ if (since !== undefined && since !== releaseTag(version)) {
118
118
  return yield* refused(
119
- `${MANIFEST} holds ${version}, but the last release tag is ${since}, so v${version} is untagged; rerun the release that bumped it, whose refusal says what to fix, or return ${MANIFEST} to ${since.slice(1)}`,
119
+ `${MANIFEST} holds ${version}, but the last release tag is ${since}, so ${releaseTag(version)} is untagged; rerun the release that bumped it, whose refusal says what to fix, or return ${MANIFEST} to ${taggedVersion(since)}`,
120
120
  );
121
121
  }
122
122
  yield* Console.log(`${LABEL}: ${MANIFEST} holds ${version}, which ${since === undefined ? "has no release tag yet" : `matches ${since}`}`);
@@ -2,12 +2,12 @@
2
2
  import { Console, Effect, FileSystem, Option, Schema } from "effect";
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  import { collect } from "../core/git.ts";
5
+ import { MANIFEST } from "../core/release-names.ts";
5
6
  import { runMain, Usage } from "../core/main.ts";
6
7
 
7
8
  const NAME = "checks-release-publish";
8
9
  const LABEL = "release-publish";
9
10
  const USAGE = "usage: release-publish.ts";
10
- const MANIFEST = "package.json";
11
11
 
12
12
  class PublishFailed extends Schema.TaggedError<PublishFailed>()("PublishFailed", {
13
13
  message: Schema.String,
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Duration, Effect, Schema } from "effect";
3
- import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
3
+ import { dispatch, gitHubJson, REPOSITORY } from "../core/github.ts";
4
4
  import { releasedVersionOf } from "./release.ts";
5
5
  import { buildRelease, commitRelease, decodeRef, hasBranch, planRelease, pointBranch, refused } from "./release-commit.ts";
6
6
  import { pushTag, tagHead, taggedOn } from "./release-tagging.ts";
7
7
  import { git } from "../core/git.ts";
8
+ import { releaseTag } from "../core/release-names.ts";
8
9
  import { runMain, Usage } from "../core/main.ts";
9
10
 
10
11
  const NAME = "checks-release-push";
@@ -124,7 +125,7 @@ const releasePush = Effect.gen(function* () {
124
125
  const plan = yield* planRelease(LABEL, root, UNTAGGED);
125
126
  if (plan === undefined) return true;
126
127
  const { base, branch, head, next, bumped } = plan;
127
- const tag = `v${next}`;
128
+ const tag = releaseTag(next);
128
129
  const taken = yield* taggedOn(root, tag);
129
130
  if (taken !== undefined) return yield* refused(`${tag} already tags ${taken.slice(0, 12)}, so ${next} cannot be released again`);
130
131
  const release = yield* commitRelease(root, head, yield* buildRelease(NAME, root, bumped), next);
@@ -23,7 +23,7 @@ const PUBLISH_BINS = ["checks-release-publish", "release-publish.ts"];
23
23
  const NUMBER = /^\d+$/;
24
24
  const BY_HAND = "drop the schedule, so a release runs only when someone starts it";
25
25
 
26
- function isBinIn(bins: readonly string[], word: string): boolean {
26
+ export function isBinIn(bins: readonly string[], word: string): boolean {
27
27
  return bins.some((name) => word === name || word.endsWith(`/${name}`));
28
28
  }
29
29
 
@@ -57,7 +57,7 @@ function workflowRuns(
57
57
  });
58
58
  }
59
59
 
60
- function crons(document: unknown): readonly unknown[] {
60
+ export function crons(document: unknown): readonly unknown[] {
61
61
  const on = isRecord(document) ? document["on"] : undefined;
62
62
  const schedule = isRecord(on) ? on["schedule"] : undefined;
63
63
  return Array.isArray(schedule) ? schedule.map((entry) => (isRecord(entry) ? entry["cron"] : undefined)) : [];
@@ -1,14 +1,14 @@
1
1
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
- import { dispatch, GitHubFailure } from "./github.ts";
2
+ import { dispatch, GitHubFailure } from "../core/github.ts";
3
3
  import { ChildProcessSpawner } from "effect/process";
4
4
  import { lastTag, releasedVersionOf, runBuild } from "./release.ts";
5
5
  import { git } from "../core/git.ts";
6
+ import { MANIFEST, releaseTag, taggedVersion } from "../core/release-names.ts";
6
7
 
7
8
  class TagRefused extends Schema.TaggedError<TagRefused>()("TagRefused", {
8
9
  message: Schema.String,
9
10
  }) {}
10
11
 
11
- const MANIFEST = "package.json";
12
12
 
13
13
  const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
14
14
 
@@ -48,7 +48,7 @@ export const tagHead = Effect.fn("tagHead")(function* (
48
48
  const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
49
49
  const { version: held } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => new TagRefused({ message: `${MANIFEST}: ${cause.message}` })));
50
50
  if (held !== version) return yield* new TagRefused({ message: `${head.slice(0, 12)} releases ${version}, but ${MANIFEST} holds ${held}` });
51
- const tag = `v${version}`;
51
+ const tag = releaseTag(version);
52
52
  const tagged = yield* taggedOn(root, tag);
53
53
  if (tagged === head) {
54
54
  yield* Console.log(`${label}: ${tag} already tags ${head.slice(0, 12)}`);
@@ -59,7 +59,7 @@ export const tagHead = Effect.fn("tagHead")(function* (
59
59
  const rebuilt = (yield* git(["diff", "--name-only"], root)).trim().split("\n").filter((file) => file !== "");
60
60
  if (rebuilt.length > 0) {
61
61
  const since = yield* lastTag(root);
62
- const returned = since === undefined ? "the version it held before this release" : since.slice(1);
62
+ const returned = since === undefined ? "the version it held before this release" : taggedVersion(since);
63
63
  return yield* new TagRefused({
64
64
  message: `the build rewrites ${rebuilt.join(", ")} at ${head.slice(0, 12)}, which the release workflow's build check refuses; open a \`chore: cancel the unpublished ${version}\` pull request that returns ${MANIFEST} to ${returned} and commits what \`bun run build\` then writes to CHANGELOG.md, and the next ${cuttingWorkflow} run cuts the release again with every change since ${since ?? "the first commit"}`,
65
65
  });
@@ -2,6 +2,7 @@ import { Config, Effect, Schema } from "effect";
2
2
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
3
  import { groupOf } from "./changelog.ts";
4
4
  import { git } from "../core/git.ts";
5
+ import { RELEASE_TAG_GLOB } from "../core/release-names.ts";
5
6
 
6
7
  class ReleaseUnreadable extends Schema.TaggedError<ReleaseUnreadable>()("ReleaseUnreadable", {
7
8
  message: Schema.String,
@@ -16,7 +17,6 @@ export type Pending = {
16
17
  readonly unreleased: readonly string[];
17
18
  };
18
19
 
19
- const RELEASE_TAG = "v[0-9]*.[0-9]*.[0-9]*";
20
20
  const PLAIN_VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
21
21
  const RELEASE_SUBJECT = /^chore: release (\d+\.\d+\.\d+)(?: \(#\d+\))?$/;
22
22
 
@@ -59,7 +59,7 @@ export const runBuild = Effect.fn("runBuild")(function* (root: string) {
59
59
  });
60
60
 
61
61
  export const lastTag = Effect.fn("lastTag")(function* (root: string) {
62
- const described = yield* git(["describe", "--tags", "--abbrev=0", `--match=${RELEASE_TAG}`, "HEAD"], root).pipe(
62
+ const described = yield* git(["describe", "--tags", "--abbrev=0", `--match=${RELEASE_TAG_GLOB}`, "HEAD"], root).pipe(
63
63
  Effect.map((tag) => tag.trim()),
64
64
  Effect.catchTag("GitFailure", () => Effect.succeed("")),
65
65
  );
@@ -1,8 +1,8 @@
1
1
  import { runsBin } from "./mutation-job.ts";
2
- import { releasesOnSchedule, schedules } from "./release-schedule.ts";
2
+ import { crons, isBinIn, releasesOnSchedule, schedules } from "./release-schedule.ts";
3
3
  import { jobRuns } from "./runner-policy.ts";
4
4
  import { invokes, plainCommand, type Command } from "./shell-command.ts";
5
- import { isRecord, locate, workflowJobs, type Workflow } from "./workflow-references.ts";
5
+ import { locate, workflowJobs, type Workflow } from "./workflow-references.ts";
6
6
  import type { ScheduleContext, ScheduleOwner } from "./release-schedule.ts";
7
7
 
8
8
  export type ScheduledRun = {
@@ -17,13 +17,7 @@ const WEEKDAY = /^(?:[0-7]|sun|mon|tue|wed|thu|fri|sat)$/i;
17
17
  const BY_HAND = "drop the schedule, so it runs only when someone starts it with gh workflow run";
18
18
 
19
19
  function isAdvisoryBin([head = ""]: Command): boolean {
20
- return ADVISORY_BINS.some((name) => head === name || head.endsWith(`/${name}`));
21
- }
22
-
23
- function crons(document: unknown): readonly unknown[] {
24
- const on = isRecord(document) ? document["on"] : undefined;
25
- const schedule = isRecord(on) ? on["schedule"] : undefined;
26
- return Array.isArray(schedule) ? schedule.map((entry) => (isRecord(entry) ? entry["cron"] : undefined)) : [];
20
+ return isBinIn(ADVISORY_BINS, head);
27
21
  }
28
22
 
29
23
  // Once a week is one minute, one hour and one weekday, on any day of any month.
@@ -3,7 +3,7 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { runMain, Usage } from "../core/main.ts";
4
4
  import { ciWorkflow } from "./setup-workflow.ts";
5
5
  import { TEMPLATES } from "../docs/doc-templates.ts";
6
- import { scriptOptions } from "../testing/test.ts";
6
+ import { scriptOptions } from "../core/test-script.ts";
7
7
  import { withServiceOverrides, type ServiceOverride } from "../quality/effect-scope.ts";
8
8
  import { SOURCES, TESTS } from "../quality/presets/oxlint.ts";
9
9
  import severities from "../quality/presets/effect.language-service.json" with { type: "json" };
@@ -102,9 +102,12 @@ function cellsOf(raw: string): readonly string[] {
102
102
 
103
103
  const DELIMITER_CELL = /^\s*:?-+:?\s*$/;
104
104
 
105
+ function isQuoteOrIndentedCode(raw: string): boolean {
106
+ return /^\s*>/.test(raw) || /^(?: {4}|\t)/.test(raw);
107
+ }
108
+
105
109
  function isDelimiterRow(raw: string): boolean {
106
- if (/^\s*>/.test(raw)) return false;
107
- if (/^(?: {4}|\t)/.test(raw)) return false;
110
+ if (isQuoteOrIndentedCode(raw)) return false;
108
111
  const trimmed = raw.trim();
109
112
  if (!trimmed.includes("|") || !trimmed.includes("-")) return false;
110
113
  const inner = trimmed.replace(/^\|/, "").replace(/\|$/, "");
@@ -125,22 +128,18 @@ function codeLinesIn(scanned: readonly MarkdownLine[]): ReadonlySet<number> {
125
128
  return excluded;
126
129
  }
127
130
 
131
+ function isOutsideCodeAndQuote(line: MarkdownLine, code: ReadonlySet<number>): boolean {
132
+ return !code.has(line.line) && !isQuoteOrIndentedCode(line.raw);
133
+ }
134
+
128
135
  function isTableCandidate(line: MarkdownLine, code: ReadonlySet<number>): boolean {
129
- if (line.kind !== "prose" && line.kind !== "table") return false;
130
- if (code.has(line.line)) return false;
131
- if (/^\s*>/.test(line.raw)) return false;
132
- if (/^(?: {4}|\t)/.test(line.raw)) return false;
133
- return true;
136
+ return (line.kind === "prose" || line.kind === "table") && isOutsideCodeAndQuote(line, code);
134
137
  }
135
138
 
136
139
  type TableRow = { readonly line: number; readonly raw: string; readonly width: number };
137
140
 
138
141
  function isBodyLine(line: MarkdownLine, code: ReadonlySet<number>): boolean {
139
- if (line.raw.trim() === "") return false;
140
- if (code.has(line.line)) return false;
141
- if (/^\s*>/.test(line.raw)) return false;
142
- if (/^(?: {4}|\t)/.test(line.raw)) return false;
143
- return true;
142
+ return line.raw.trim() !== "" && isOutsideCodeAndQuote(line, code);
144
143
  }
145
144
 
146
145
  type HeaderMatch = { readonly header: MarkdownLine; readonly delimiterLine: number; readonly width: number };
@@ -32,6 +32,15 @@ const BASE = {
32
32
  from: {},
33
33
  to: { circular: true },
34
34
  },
35
+ {
36
+ name: "no-folder-cycle",
37
+ severity: "error",
38
+ comment:
39
+ "Folders reach each other in a cycle, so none stands without the others. Move the module that reaches back into the folder it imports, or move what the folders need into a third folder they all import.",
40
+ scope: "folder",
41
+ from: {},
42
+ to: { circular: true },
43
+ },
35
44
  {
36
45
  name: "no-orphans",
37
46
  severity: "error",
@@ -3,7 +3,8 @@ import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } fro
3
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
- import { scriptOptions, workerCount } from "./test.ts";
6
+ import { scriptOptions } from "../core/test-script.ts";
7
+ import { workerCount } from "./test.ts";
7
8
 
8
9
  export type Run = {
9
10
  readonly seed: number;
@@ -4,7 +4,7 @@ import { workingTreeFiles } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { ENTRY_POINT, TEST_ENTRY_POINT } from "../core/gates.ts";
6
6
  import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "../core/swc.ts";
7
- import { scriptOptions } from "./test.ts";
7
+ import { scriptOptions } from "../core/test-script.ts";
8
8
 
9
9
  export type Violation = {
10
10
  readonly file: string;
@@ -1,8 +1,9 @@
1
1
  import { Effect, FileSystem, Path, Schema } from "effect";
2
2
  import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "../core/swc.ts";
3
+ import type { TestTier } from "../core/test-script.ts";
3
4
 
4
5
  export type Environment = "ci" | "local";
5
- export type TestTier = "live" | "pixel" | "unit" | "e2e";
6
+ export type { TestTier };
6
7
  export type SkipDeclaration = {
7
8
  readonly file: string;
8
9
  readonly line: number;
@@ -4,6 +4,7 @@ import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effec
4
4
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
5
5
  import { TEST_ENTRY_POINT } from "../core/gates.ts";
6
6
  import { runMain, Usage } from "../core/main.ts";
7
+ import { FULL, testOptions, type TestOptions, type TestTier } from "../core/test-script.ts";
7
8
  import { readPullRequestNarrowing } from "./affected-tests.ts";
8
9
  import { changedE2eFiles } from "./changed-tests.ts";
9
10
  import {
@@ -15,11 +16,11 @@ import {
15
16
  unitTierPlus,
16
17
  type Environment,
17
18
  type SkipDeclaration,
18
- type TestTier,
19
19
  } from "./test-skips.ts";
20
20
  import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
21
21
 
22
22
  export type { Environment, SkipDeclaration } from "./test-skips.ts";
23
+ export { scriptOptions, testOptions, type TestOptions } from "../core/test-script.ts";
23
24
 
24
25
  export type Verdict = {
25
26
  readonly environment: Environment;
@@ -33,44 +34,11 @@ export class SkipGateError extends Schema.TaggedError<SkipGateError>()("SkipGate
33
34
  message: Schema.String,
34
35
  }) {}
35
36
 
36
- export type TestOptions = {
37
- readonly tier?: TestTier;
38
- readonly workers?: number;
39
- readonly full?: boolean;
40
- };
41
-
42
37
  const NAME = TEST_ENTRY_POINT.bin;
43
38
  const USAGE = `usage: ${NAME} [--tier=live | --tier=pixel | --tier=unit | --tier=e2e | --full | --changed] [--parallel=<workers>] [--junit=<file>], and it refuses test filters`;
44
- const FULL = "--full";
45
39
  const CHANGED = "--changed";
46
- const TIERS = new Map<string, TestTier>([
47
- ["--tier=live", "live"],
48
- ["--tier=pixel", "pixel"],
49
- ["--tier=unit", "unit"],
50
- ["--tier=e2e", "e2e"],
51
- ]);
52
- const PARALLEL = /^--parallel=([1-9][0-9]*)$/;
53
40
  const JUNIT = /^--junit=(.+)$/;
54
41
 
55
- function optionOf(arg: string): TestOptions | undefined {
56
- const tier = TIERS.get(arg);
57
- if (tier !== undefined) return { tier };
58
- if (arg === FULL) return { full: true };
59
- const workers = PARALLEL.exec(arg)?.[1];
60
- return workers === undefined ? undefined : { workers: Number(workers) };
61
- }
62
-
63
- export function testOptions(args: readonly string[]): TestOptions | undefined {
64
- let options: TestOptions = {};
65
- for (const arg of args) {
66
- const option = optionOf(arg);
67
- if (option === undefined || Object.keys(option).some((key) => key in options)) return undefined;
68
- options = { ...options, ...option };
69
- }
70
- if (options.tier !== undefined && options.full === true) return undefined;
71
- return options;
72
- }
73
-
74
42
  export type EntryOptions = {
75
43
  readonly options: TestOptions;
76
44
  readonly junit: Option.Option<string>;
@@ -87,14 +55,6 @@ export function entryOptions(args: readonly string[]): EntryOptions | undefined
87
55
  return { options, junit: Option.fromNullishOr(junit[0]), changed: changed === 1 };
88
56
  }
89
57
 
90
- const TEST_SCRIPTS: readonly string[] = [NAME, `bun src/${TEST_ENTRY_POINT.script}`];
91
-
92
- export function scriptOptions(script: unknown): TestOptions | undefined {
93
- if (typeof script !== "string") return undefined;
94
- const entry = TEST_SCRIPTS.find((candidate) => script === candidate || script.startsWith(`${candidate} `));
95
- return entry === undefined ? undefined : testOptions(script.slice(entry.length).split(" ").filter((arg) => arg !== ""));
96
- }
97
-
98
58
  function matches(declaration: SkipDeclaration, result: TestResult): boolean {
99
59
  const { name } = declaration;
100
60
  return (
@@ -3,7 +3,7 @@ import { Config, Console, Context, Effect, FileSystem, Layer, Option, Path, Sche
3
3
  import { ChildProcessSpawner } from "effect/process";
4
4
  import { DEFAULT_BRANCH } from "../core/gates.ts";
5
5
  import { runMain, Usage } from "../core/main.ts";
6
- import { gh, type GitHubFailure, gitHubJson, REPOSITORY } from "../delivery/github.ts";
6
+ import { gh, type GitHubFailure, gitHubJson, REPOSITORY } from "../core/github.ts";
7
7
  import { parseReport as parseMutationReport, type ReportFile } from "./mutation-compare.ts";
8
8
  import { cleared, type Finding, findingsOf, gatesDecided, gatesOf, keyOf, WholeTreeRecordSchema, noted, type Observed, type WholeTreeRecord, percent, type PreviousRun, scoresOf, type TestReport } from "./whole-tree-run-findings.ts";
9
9
  import { parseReport as parseTestReport } from "./test-report.ts";