@avi2dg/checks 0.36.0 → 0.37.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.
@@ -25,16 +25,16 @@ It reads the repository's Knip configuration, which names the entries.
25
25
  It reads `exports-baseline.json` in the repository root, which holds each accepted unused export as a file, kind and name triple.
26
26
  It reads `exports-baseline.json` again at the base of the range, where a commit without the file counts as empty.
27
27
  When the range adds baseline entries, it runs Knip again on a temporary checkout of the base and removes the checkout when it ends.
28
- That checkout links the repository's `node_modules`, so a configuration importing the kit's base loads there too.
29
- Knip configurations do not extend a package file, so the consumer configuration imports the kit's base and spreads it:
28
+ That checkout links the repository's `node_modules`, so a configuration importing the kit's builder loads there too.
29
+ The repository writes `knip.config.ts` with the kit's builder, which requires `entry`, the files nothing imports:
30
30
 
31
31
  ```ts
32
- import base from "@avi2dg/checks/knip-base.json";
32
+ import { defineConfig } from "@avi2dg/checks/knip";
33
33
 
34
- export default { ...base, entry: ["src/index.ts", "tests/**/*.test.ts"] };
34
+ export default defineConfig({ entry: ["src/index.ts"] });
35
35
  ```
36
36
 
37
- The base in `knip-base.json` reports only unreferenced files.
37
+ The builder sets `include` to unreferenced files alone, and adds `tests/**/*.test.ts` and `dependency-cruiser.config.ts` to the entries.
38
38
  The gate resolves the Knip binary from the installed kit, so a consumer installs nothing beyond the kit.
39
39
 
40
40
  ## Arguments
@@ -0,0 +1,64 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-imports
6
+
7
+ `checks-imports` is the gate that holds a repository's imports to the dependency rules, with the kit's defaults when the repository writes no config.
8
+
9
+ ## What it checks
10
+
11
+ 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.
12
+ It fails on a violation whose rule has `error` severity, and lists a `warn` or `info` violation without failing.
13
+ The rules are those [The dependency rules](../configs/dependency-rules.md) lists, plus each rule the repository's config adds.
14
+
15
+ In a repository whose `package.json` lists `astro` under `dependencies` or `devDependencies`, the kit's defaults leave `no-orphans` out.
16
+ The cruise never reads an `.astro` file, so a `.ts` module only a page imports would read as an orphan.
17
+ [checks-unused](checks-unused.md) already names each file no entry reaches there, and it reads `.astro` imports.
18
+ A repository that wants the rule back lists its own `no-orphans` under `forbidden` in `dependency-cruiser.config.ts`.
19
+
20
+ ## What it reads
21
+
22
+ It reads the working tree from the repository root, and leaves out a tracked file the working tree no longer holds.
23
+ It cruises against the first config it finds there:
24
+
25
+ 1. `dependency-cruiser.config.ts`, which calls `defineConfig` from `@avi2dg/checks/dependency-cruiser`.
26
+ 1. A config under one of dependency-cruiser's own names, such as `.dependency-cruiser.cjs`.
27
+ 1. The kit's defaults, which `defineConfig()` returns with no argument.
28
+
29
+ It runs dependency-cruiser from the installed kit under Bun, which loads a TypeScript config wherever it sits.
30
+
31
+ ## Arguments
32
+
33
+ It takes none.
34
+
35
+ ## Exit codes
36
+
37
+ | Code | When |
38
+ | --- | --- |
39
+ | 0 | no import breaks an `error` rule |
40
+ | 1 | an import breaks an `error` rule |
41
+ | 2 | the repository tracks no TypeScript, or dependency-cruiser cannot load the config or run |
42
+
43
+ ## Sample output
44
+
45
+ ```
46
+ imports: 2 violation(s) in 36 module(s) cruised against dependency-cruiser.config.ts
47
+ error not-to-dev-dep: src/index.ts → node_modules/effect/dist/index.js
48
+ error no-orphans: src/lonely.ts
49
+ ```
50
+
51
+ A passing run counts the modules it cruised:
52
+
53
+ ```
54
+ imports: 225 module(s) cruised against dependency-cruiser.config.ts, no violation
55
+ ```
56
+
57
+ ## When it runs
58
+
59
+ `checks-lint` runs it when the repository tracks a `.ts` or `.tsx` file, so a repository with no config of its own still has its imports judged.
60
+
61
+ ## Related topics
62
+
63
+ - [The dependency rules](../configs/dependency-rules.md)
64
+ - [checks-lint](checks-lint.md)
@@ -47,7 +47,7 @@ With two arguments, the base and head override range discovery.
47
47
 
48
48
  ```
49
49
  checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
50
- checks-lint: 1 of 14 gate(s) failed: checks-comment-gate
50
+ checks-lint: 1 of 16 gate(s) failed: checks-comment-gate
51
51
  ```
52
52
 
53
53
  <!-- end generated lint-sample -->
@@ -4,7 +4,7 @@ audience: consumers
4
4
  ---
5
5
  # checks-mutation-compare
6
6
 
7
- `checks-mutation-compare` is the gate that fails a pull request when any mutant regresses, rather than judging an absolute score.
7
+ `checks-mutation-compare` compares the mutants of two reports and lists each one that regresses, rather than judging an absolute score.
8
8
 
9
9
  ## What it checks
10
10
 
@@ -68,7 +68,10 @@ mutation-compare: REGRESSION (1 mutant(s))
68
68
  ## When it runs
69
69
 
70
70
  Only a CI step the repository writes runs it.
71
- A repository runs it with `--advisory` for its first month, then drops the flag so it blocks.
71
+ A repository runs it with `--advisory`, so the comparison stays advisory and never blocks a pull request.
72
+ Its lines land only in the CI log.
73
+ A repository that wants a reviewer to answer each regression line puts the lines in front of that reviewer with its own CI step.
74
+ That step can post them as a pull request comment.
72
75
  A full sweep runs in CI and never on a laptop.
73
76
  Start a baseline with `gh workflow run mutation` and keep its report as an artifact.
74
77
  The shared preset refuses a full `stryker run` outside CI and names that workflow command instead, as [checks-mutation](checks-mutation.md) says.
@@ -75,9 +75,9 @@ An `--incremental` run with no report stops before instrumenting with the missin
75
75
  ## When it runs
76
76
 
77
77
  A repository runs full baselines from the mutation workflow on `workflow_dispatch`.
78
- A repository that picks a pull request scope from a baseline also runs that workflow on a nightly schedule.
78
+ A repository that picks a pull request scope from a baseline starts that workflow weekly with `gh workflow run mutation`, because a GitHub schedule can start hours late.
79
79
  Run scoped checks locally during development.
80
- A scheduled run costs a full Stryker run.
80
+ Each baseline costs a full Stryker run.
81
81
 
82
82
  ## Running it in CI
83
83
 
@@ -18,15 +18,15 @@ It fails when the repository holds no Knip configuration, since Knip's default e
18
18
 
19
19
  It reads the working tree, so an uncommitted file is judged like a committed one.
20
20
  It reads the repository's Knip configuration, which names the entries.
21
- Knip configurations do not extend a package file, so the consumer configuration imports the kit's base and spreads it:
21
+ The repository writes `knip.config.ts` with the kit's builder, which requires `entry`, the files nothing imports:
22
22
 
23
23
  ```ts
24
- import base from "@avi2dg/checks/knip-base.json";
24
+ import { defineConfig } from "@avi2dg/checks/knip";
25
25
 
26
- export default { ...base, entry: ["src/index.ts", "tests/**/*.test.ts"] };
26
+ export default defineConfig({ entry: ["src/index.ts"] });
27
27
  ```
28
28
 
29
- The base in `knip-base.json` reports only unreferenced files.
29
+ The builder sets `include` to unreferenced files alone, and adds `tests/**/*.test.ts` and `dependency-cruiser.config.ts` to the entries.
30
30
  In a repository that depends on `astro`, Knip reads `.astro` imports, so a file only an `.astro` entry imports counts as used and an `.astro` file no entry reaches is named.
31
31
  The gate resolves the Knip binary from the installed kit, so a consumer installs nothing beyond the kit.
32
32
 
package/oxlintrc.json CHANGED
@@ -1,19 +1,48 @@
1
1
  {
2
- "plugins": ["typescript", "oxc", "eslint", "import"],
3
- "jsPlugins": ["./dist/effect-channel/index.js", "./dist/readability/index.js", "./dist/data-shape/index.js"],
4
- "categories": { "correctness": "error", "suspicious": "error" },
2
+ "plugins": [
3
+ "typescript",
4
+ "oxc",
5
+ "eslint",
6
+ "import"
7
+ ],
8
+ "jsPlugins": [
9
+ "./dist/effect-channel/index.js",
10
+ "./dist/readability/index.js",
11
+ "./dist/data-shape/index.js"
12
+ ],
13
+ "categories": {
14
+ "correctness": "error",
15
+ "suspicious": "error"
16
+ },
5
17
  "rules": {
6
18
  "effect-channel/no-error-channel-escape": "error",
7
19
  "typescript/no-explicit-any": "error",
8
- "typescript/ban-ts-comment": ["error", { "ts-expect-error": true }],
20
+ "typescript/ban-ts-comment": [
21
+ "error",
22
+ {
23
+ "ts-expect-error": true
24
+ }
25
+ ],
9
26
  "typescript/no-inferrable-types": "error",
10
27
  "typescript/explicit-module-boundary-types": "error",
11
28
  "typescript/switch-exhaustiveness-check": "error",
12
29
  "typescript/prefer-readonly": "error",
13
- "typescript/no-unnecessary-condition": ["error", { "allowConstantLoopConditions": true }],
30
+ "typescript/no-unnecessary-condition": [
31
+ "error",
32
+ {
33
+ "allowConstantLoopConditions": true
34
+ }
35
+ ],
14
36
  "typescript/no-unnecessary-type-parameters": "error",
15
37
  "typescript/use-unknown-in-catch-callback-variable": "error",
16
- "eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "ignoreRestSiblings": true }],
38
+ "eslint/no-unused-vars": [
39
+ "error",
40
+ {
41
+ "argsIgnorePattern": "^_",
42
+ "varsIgnorePattern": "^_",
43
+ "ignoreRestSiblings": true
44
+ }
45
+ ],
17
46
  "typescript/no-unsafe-type-assertion": "error",
18
47
  "typescript/no-non-null-assertion": "error",
19
48
  "typescript/no-deprecated": "error",
@@ -21,14 +50,22 @@
21
50
  },
22
51
  "overrides": [
23
52
  {
24
- "files": ["**/*.ts", "**/*.tsx"],
53
+ "files": [
54
+ "**/*.ts",
55
+ "**/*.tsx"
56
+ ],
25
57
  "rules": {
26
58
  "data-shape/readonly-collection-param": "error"
27
59
  }
28
60
  },
29
61
  {
30
- "files": ["**/*.ts", "**/*.tsx"],
31
- "excludeFiles": ["tests/**"],
62
+ "files": [
63
+ "**/*.ts",
64
+ "**/*.tsx"
65
+ ],
66
+ "excludeFiles": [
67
+ "tests/**"
68
+ ],
32
69
  "rules": {
33
70
  "data-shape/schema-twin": "error",
34
71
  "typescript/no-unsafe-assignment": "error",
@@ -39,7 +76,9 @@
39
76
  }
40
77
  },
41
78
  {
42
- "files": ["**/*.astro"],
79
+ "files": [
80
+ "**/*.astro"
81
+ ],
43
82
  "rules": {
44
83
  "readability/thin-astro": "error",
45
84
  "import/no-unassigned-import": "off"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -80,15 +80,21 @@
80
80
  "src/docs/doc-templates.ts",
81
81
  "src/docs/doc-names.ts",
82
82
  "dist/templates/",
83
- "src/quality/presets/effect.oxlint.json",
84
83
  "src/quality/presets/effect.language-service.json",
84
+ "src/quality/presets/oxlint.ts",
85
+ "src/quality/presets/dependency-cruiser.ts",
86
+ "src/quality/effect-scope.ts",
87
+ "src/quality/jsonc-patch.ts",
88
+ "src/dependencies/imports.ts",
89
+ "src/dependencies/kit-defaults.ts",
85
90
  "oxlintrc.json",
86
91
  "stryker.preset.js",
87
92
  "tsconfig.effect.json",
88
93
  "ts-reset.d.ts",
89
94
  "dist/effect-channel/index.js",
90
95
  "dist/readability/index.js",
91
- "dist/data-shape/index.js"
96
+ "dist/data-shape/index.js",
97
+ "dist/presets/"
92
98
  ],
93
99
  "exports": {
94
100
  "./dependency-cruiser.config.js": "./dependency-cruiser.config.js",
@@ -99,7 +105,19 @@
99
105
  "./stryker.preset.js": "./stryker.preset.js",
100
106
  "./templates/*": "./dist/templates/*",
101
107
  "./tsconfig.effect.json": "./tsconfig.effect.json",
102
- "./browser-hooks.ts": "./src/quality/browser/hooks.ts"
108
+ "./browser-hooks.ts": "./src/quality/browser/hooks.ts",
109
+ "./oxlint": {
110
+ "types": "./dist/presets/oxlint.d.ts",
111
+ "default": "./dist/presets/oxlint.js"
112
+ },
113
+ "./knip": {
114
+ "types": "./dist/presets/knip.d.ts",
115
+ "default": "./dist/presets/knip.js"
116
+ },
117
+ "./dependency-cruiser": {
118
+ "types": "./dist/presets/dependency-cruiser.d.ts",
119
+ "default": "./dist/presets/dependency-cruiser.js"
120
+ }
103
121
  },
104
122
  "bin": {
105
123
  "checks-lint": "src/core/lint.ts",
@@ -128,12 +146,14 @@
128
146
  "checks-advisories": "src/dependencies/advisories.ts",
129
147
  "checks-secrets": "src/delivery/secrets.ts",
130
148
  "checks-frontend-syntax": "src/quality/frontend-syntax.ts",
131
- "checks-browser": "src/quality/browser/browser.ts"
149
+ "checks-browser": "src/quality/browser/browser.ts",
150
+ "checks-imports": "src/dependencies/imports.ts",
151
+ "checks-effect-scope": "src/quality/effect-scope.ts"
132
152
  },
133
153
  "scripts": {
134
154
  "prepare": "bun src/dependencies/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
135
- "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun build src/quality/data-shape/index.ts --outdir dist/data-shape --target node --format esm && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
136
- "lint": "oxlint --type-aware && bun src/core/lint.ts && depcruise --config .dependency-cruiser.cjs .",
155
+ "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun build src/quality/data-shape/index.ts --outdir dist/data-shape --target node --format esm && bun build src/quality/presets/oxlint.ts src/quality/presets/knip.ts src/quality/presets/dependency-cruiser.ts --outdir dist/presets --target node --format esm && tsc -p tsconfig.presets.json && bun scripts/configs-write.ts && bun src/quality/effect-scope.ts && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
156
+ "lint": "oxlint && bun src/core/lint.ts",
137
157
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
138
158
  "test": "bun src/testing/test.ts",
139
159
  "mutate": "bunx stryker run",
@@ -130,7 +130,7 @@ export function report(head: Baseline, { unlisted, added, stale }: Drift): strin
130
130
 
131
131
  const scan = scanTree(NAME, INCLUDED, TYPESCRIPT_SOURCE).pipe(Effect.mapError((cause) => new ExportsError({ message: cause.message })));
132
132
 
133
- // Knip loads the configuration's imports, such as the kit's knip-base.json, from a node_modules the checkout lacks.
133
+ // Knip loads the configuration's imports, such as the kit's knip builder, from a node_modules the checkout lacks.
134
134
  const unusedAt = Effect.fn("unusedAt")(
135
135
  function* (rev: string, root: string) {
136
136
  const files = yield* pathsAt(rev, [], root);
@@ -63,7 +63,7 @@ export const scanTree = Effect.fn("scanTree")(function* (name: string, args: rea
63
63
  if (tracked.length === 0) return yield* new KnipError({ message: `no tracked ${source.content} to scan` });
64
64
  const reported = yield* knipReport(root, args);
65
65
  if (reported.kind === "unconfigured") {
66
- yield* Console.log(`${name}: no knip configuration names entry files, so add one extending the kit's knip-base.json`);
66
+ yield* Console.log(`${name}: no knip configuration names entry files, so add a knip.config.ts calling defineConfig from @avi2dg/checks/knip`);
67
67
  }
68
68
  return { root, tracked: tracked.length, reported };
69
69
  });
package/src/core/gates.ts CHANGED
@@ -36,6 +36,8 @@ export const LINTED_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx", "*.a
36
36
 
37
37
  const BUN_LOCKFILE: TrackedContent = { pathspecs: ["bun.lock"], content: "a bun lockfile" };
38
38
 
39
+ const OXLINT_CONFIG: TrackedContent = { pathspecs: ["oxlint.config.ts"], content: "an oxlint.config.ts" };
40
+
39
41
  const FRONTEND_SYNTAX_DECLARATION: TrackedContent = { pathspecs: ["frontend-syntax.json"], content: "a frontend syntax declaration" };
40
42
 
41
43
  export const KIT_GATES = [
@@ -43,6 +45,7 @@ export const KIT_GATES = [
43
45
  { bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
44
46
  { bin: "checks-commit-identity", vector: "delivery", file: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
45
47
  { bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
48
+ { bin: "checks-effect-scope", vector: "quality", file: "effect-scope.ts", reads: "tree", args: ["--check"], appliesTo: OXLINT_CONFIG },
46
49
  { bin: "checks-frontend-syntax", vector: "quality", file: "frontend-syntax.ts", reads: "tree", appliesTo: FRONTEND_SYNTAX_DECLARATION },
47
50
  { bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
48
51
  { bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
@@ -52,5 +55,6 @@ export const KIT_GATES = [
52
55
  { bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: LINTED_SOURCE },
53
56
  { bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
54
57
  { bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
58
+ { bin: "checks-imports", vector: "dependencies", file: "imports.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
55
59
  { bin: "checks-advisories", vector: "dependencies", file: "advisories.ts", reads: "range", appliesTo: BUN_LOCKFILE },
56
60
  ] as const satisfies readonly KitGate[];
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { collect, git } from "../core/git.ts";
4
+ import { runMain, Usage } from "../core/main.ts";
5
+
6
+ export const PROJECT_CONFIG = "dependency-cruiser.config.ts";
7
+
8
+ export const OWN_CONFIGS = [
9
+ ".dependency-cruiser.json",
10
+ ".dependency-cruiser.js",
11
+ ".dependency-cruiser.cjs",
12
+ ".dependency-cruiser.mjs",
13
+ ".dependency-cruiser.ts",
14
+ ".dependency-cruiser.cts",
15
+ ".dependency-cruiser.mts",
16
+ ] as const;
17
+
18
+ export const CRUISED = ["*.ts", "*.tsx", "*.mts", "*.cts"] as const;
19
+
20
+ const NAME = "imports";
21
+ const USAGE = "usage: imports.ts";
22
+ const KIT_DEFAULTS = "kit-defaults.ts";
23
+
24
+ class ImportsError extends Schema.TaggedError<ImportsError>()("ImportsError", {
25
+ message: Schema.String,
26
+ }) {}
27
+
28
+ const Violation = Schema.Struct({
29
+ from: Schema.String,
30
+ to: Schema.String,
31
+ rule: Schema.Struct({ name: Schema.String, severity: Schema.String }),
32
+ });
33
+
34
+ export type Violation = typeof Violation.Type;
35
+
36
+ const decodeCruise = Schema.decodeUnknownEffect(
37
+ Schema.fromJsonString(
38
+ Schema.Struct({
39
+ summary: Schema.Struct({ violations: Schema.Array(Violation), error: Schema.Int, totalCruised: Schema.Int }),
40
+ }),
41
+ ),
42
+ );
43
+
44
+ export function describeViolation({ from, to, rule }: Violation): string {
45
+ return from === to ? ` ${rule.severity} ${rule.name}: ${from}` : ` ${rule.severity} ${rule.name}: ${from} → ${to}`;
46
+ }
47
+
48
+ const configOf = Effect.fn("configOf")(function* (root: string) {
49
+ const fs = yield* FileSystem.FileSystem;
50
+ const path = yield* Path.Path;
51
+ for (const name of [PROJECT_CONFIG, ...OWN_CONFIGS]) {
52
+ if (yield* fs.exists(path.join(root, name))) return { file: name, shown: name };
53
+ }
54
+ return { file: path.join(import.meta.dir, KIT_DEFAULTS), shown: "the kit's defaults" };
55
+ });
56
+
57
+ const depcruise = Effect.fn("depcruise")(function* () {
58
+ const path = yield* Path.Path;
59
+ const main = yield* Effect.try({
60
+ try: () => new URL("../../bin/dependency-cruiser.mjs", import.meta.resolve("dependency-cruiser")),
61
+ catch: () => new ImportsError({ message: "cannot resolve dependency-cruiser, a peer dependency of the kit" }),
62
+ });
63
+ return yield* path.fromFileUrl(main);
64
+ });
65
+
66
+ const trackedInTree = Effect.fn("trackedInTree")(function* (root: string) {
67
+ const fs = yield* FileSystem.FileSystem;
68
+ const path = yield* Path.Path;
69
+ const listed = yield* git(["ls-files", "-z", "--", ...CRUISED], root);
70
+ return yield* Effect.filter(listed.split("\0").filter(Boolean), (file) => fs.exists(path.join(root, file)), { concurrency: "unbounded" });
71
+ });
72
+
73
+ const imports = Effect.gen(function* () {
74
+ if (process.argv.length > 2) return yield* new Usage({ message: USAGE });
75
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
76
+ const files = yield* trackedInTree(root);
77
+ if (files.length === 0) return yield* new ImportsError({ message: "no tracked TypeScript to cruise" });
78
+ const config = yield* configOf(root);
79
+ const run = yield* collect(process.execPath, [yield* depcruise(), "--config", config.file, "--output-type", "json", ...files], root).pipe(
80
+ Effect.mapError((cause) => new ImportsError({ message: `cannot run dependency-cruiser: ${cause.message}` })),
81
+ );
82
+ const { summary } = yield* decodeCruise(run.stdout).pipe(
83
+ Effect.mapError(() => new ImportsError({ message: `dependency-cruiser exits ${run.exitCode} with no report: ${(run.stdout + run.stderr).trim()}` })),
84
+ );
85
+ const cruised = `${summary.totalCruised} module(s) cruised against ${config.shown}`;
86
+ if (summary.violations.length === 0) {
87
+ yield* Console.log(`${NAME}: ${cruised}, no violation`);
88
+ return true;
89
+ }
90
+ yield* Console.error([`${NAME}: ${summary.violations.length} violation(s) in ${cruised}`, ...summary.violations.map(describeViolation)].join("\n"));
91
+ return summary.error === 0;
92
+ });
93
+
94
+ if (import.meta.main) runMain(NAME, imports);
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from "../quality/presets/dependency-cruiser.ts";
2
+
3
+ export default defineConfig();
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { git } from "../core/git.ts";
4
+ import { runMain, Usage } from "../core/main.ts";
5
+ import { hasMember, type Step, withMember, withoutMember } from "./jsonc-patch.ts";
6
+ import { effectRules } from "./presets/oxlint.ts";
7
+ import severities from "./presets/effect.language-service.json" with { type: "json" };
8
+ import kitTsconfig from "../../tsconfig.effect.json" with { type: "json" };
9
+
10
+ export const OXLINT_CONFIG = "oxlint.config.ts";
11
+ export const TSCONFIG = "tsconfig.json";
12
+ export const LANGUAGE_SERVICE = "@effect/language-service";
13
+
14
+ const NAME = "effect-scope";
15
+ const CHECK = "--check";
16
+ const USAGE = `usage: effect-scope.ts [${CHECK}]`;
17
+
18
+ class EffectScopeError extends Schema.TaggedError<EffectScopeError>()("EffectScopeError", {
19
+ message: Schema.String,
20
+ }) {}
21
+
22
+ const JsonObject = Schema.Record(Schema.String, Schema.Unknown);
23
+
24
+ type JsonObject = typeof JsonObject.Type;
25
+
26
+ const Override = Schema.Struct({
27
+ files: Schema.Array(Schema.String),
28
+ excludeFiles: Schema.optionalKey(Schema.Array(Schema.String)),
29
+ rules: Schema.optionalKey(JsonObject),
30
+ });
31
+
32
+ const OxlintModule = Schema.Struct({ default: Schema.Struct({ overrides: Schema.optionalKey(Schema.Array(Override)) }) });
33
+
34
+ const decodeOxlintModule = Schema.decodeUnknownEffect(OxlintModule);
35
+ const decodeObject = Schema.decodeUnknownEffect(JsonObject);
36
+ const decodePlugins = Schema.decodeUnknownEffect(Schema.Array(JsonObject));
37
+
38
+ export type ServiceOverride = {
39
+ readonly include: readonly string[];
40
+ readonly exclude?: readonly string[];
41
+ readonly options: typeof severities;
42
+ };
43
+
44
+ const EFFECT_RULES = JSON.stringify(effectRules([]).rules);
45
+
46
+ const SERVICE: readonly [Step, ...Step[]] = ["compilerOptions", "plugins", { name: LANGUAGE_SERVICE }];
47
+
48
+ const SERVICE_OVERRIDES: readonly [Step, ...Step[]] = [...SERVICE, "overrides"];
49
+
50
+ const SERVICE_SEVERITY: readonly [Step, ...Step[]] = [...SERVICE, "diagnosticSeverity"];
51
+
52
+ const KIT_SEVERITY = kitTsconfig.compilerOptions.plugins.find(({ name }) => name === LANGUAGE_SERVICE)?.diagnosticSeverity ?? {};
53
+
54
+ export function serviceOverrides(overrides: readonly (typeof Override.Type)[]): readonly ServiceOverride[] {
55
+ return overrides
56
+ .filter(({ rules }) => JSON.stringify(rules) === EFFECT_RULES)
57
+ .map(({ files, excludeFiles = [] }) => (excludeFiles.length === 0 ? { include: files, options: severities } : { include: files, exclude: excludeFiles, options: severities }));
58
+ }
59
+
60
+ export function withServiceOverrides(tsconfig: string, overrides: readonly ServiceOverride[]): string {
61
+ if (!hasMember(tsconfig, SERVICE)) return overrides.length === 0 ? tsconfig : withMember(tsconfig, SERVICE, { diagnosticSeverity: KIT_SEVERITY, overrides });
62
+ const paths = overrides.length === 0 ? withoutMember(tsconfig, SERVICE_OVERRIDES) : withMember(tsconfig, SERVICE_OVERRIDES, overrides);
63
+ return Object.entries(KIT_SEVERITY).reduce((text, [key, severity]) => withMember(text, [...SERVICE_SEVERITY, key], severity), paths);
64
+ }
65
+
66
+ const refuse = (message: string) => () => new EffectScopeError({ message });
67
+
68
+ const readScope = Effect.fn("readScope")(function* (file: string) {
69
+ const path = yield* Path.Path;
70
+ const url = yield* path.toFileUrl(file).pipe(Effect.mapError(refuse(`cannot address ${file}`)));
71
+ const loaded = yield* Effect.tryPromise({
72
+ try: (): Promise<unknown> => import(url.href),
73
+ catch: (cause) => new EffectScopeError({ message: `cannot load ${OXLINT_CONFIG}: ${String(cause)}` }),
74
+ });
75
+ const { default: config } = yield* decodeOxlintModule(loaded).pipe(Effect.mapError(refuse(`${OXLINT_CONFIG} exports no oxlint config as its default`)));
76
+ return serviceOverrides(config.overrides ?? []);
77
+ });
78
+
79
+ const placeOfService = Effect.fn("placeOfService")(function* (tsconfig: JsonObject) {
80
+ const compilerOptions = yield* decodeObject("compilerOptions" in tsconfig ? tsconfig["compilerOptions"] : {}).pipe(Effect.mapError(refuse(`${TSCONFIG} holds compilerOptions that is not an object`)));
81
+ const plugins = yield* decodePlugins("plugins" in compilerOptions ? compilerOptions["plugins"] : []).pipe(Effect.mapError(refuse(`${TSCONFIG} holds compilerOptions.plugins that is not a list of objects`)));
82
+ const service = plugins.find(({ name }) => name === LANGUAGE_SERVICE) ?? {};
83
+ yield* decodeObject("diagnosticSeverity" in service ? service["diagnosticSeverity"] : {}).pipe(Effect.mapError(refuse(`${TSCONFIG} holds a ${LANGUAGE_SERVICE} diagnosticSeverity that is not an object`)));
84
+ });
85
+
86
+ const effectScope = Effect.gen(function* () {
87
+ const args = process.argv.slice(2);
88
+ if (args.length > 1 || (args.length === 1 && args[0] !== CHECK)) return yield* new Usage({ message: USAGE });
89
+ const fs = yield* FileSystem.FileSystem;
90
+ const path = yield* Path.Path;
91
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
92
+ const tsconfigFile = path.join(root, TSCONFIG);
93
+ if (!(yield* fs.exists(tsconfigFile))) {
94
+ yield* Console.log(`${NAME}: no ${TSCONFIG}, so no language service holds the Effect paths`);
95
+ return true;
96
+ }
97
+ const overrides = yield* readScope(path.join(root, OXLINT_CONFIG));
98
+ const text = yield* fs.readFileString(tsconfigFile);
99
+ const unparsed = refuse(`${TSCONFIG} does not parse as a JSONC object`);
100
+ const tsconfig = yield* Effect.try({ try: () => Bun.JSONC.parse(text), catch: unparsed }).pipe(Effect.flatMap(decodeObject), Effect.mapError(unparsed));
101
+ yield* placeOfService(tsconfig);
102
+ const written = withServiceOverrides(text, overrides);
103
+ if (written === text) {
104
+ yield* Console.log(`${NAME}: ${TSCONFIG} holds the Effect paths of ${OXLINT_CONFIG} and the kit's severities`);
105
+ return true;
106
+ }
107
+ if (args[0] === CHECK) {
108
+ yield* Console.error(`${NAME}: the ${LANGUAGE_SERVICE} entry in ${TSCONFIG} differs from the Effect paths of ${OXLINT_CONFIG} or the kit's severities; run checks-effect-scope to rewrite them`);
109
+ return false;
110
+ }
111
+ yield* fs.writeFileString(tsconfigFile, written);
112
+ yield* Console.log(`${NAME}: wrote the Effect paths of ${OXLINT_CONFIG} and the kit's severities to ${TSCONFIG}`);
113
+ return true;
114
+ });
115
+
116
+ if (import.meta.main) runMain(NAME, effectScope);