@avi2dg/checks 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ // scripts/feature-rules.ts
2
+ import { Schema as Schema3 } from "effect";
3
+
4
+ // scripts/quality-file.ts
5
+ import { Console, Effect, FileSystem, JsonSchema, Path, Schema as Schema2 } from "effect";
6
+
7
+ // scripts/gates.ts
8
+ import { Schema } from "effect";
9
+ var EVERY_REPOSITORY = "every repository";
10
+ var QUALITY_FILE = "quality.json";
11
+ var TYPESCRIPT_SOURCE = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
12
+ var QUALITY_DECLARATION = { pathspecs: [QUALITY_FILE], content: `a ${QUALITY_FILE}` };
13
+ var KIT_GATES = [
14
+ { bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
15
+ { bin: "checks-test-layout", script: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
16
+ { bin: "checks-commit-identity", script: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
17
+ { bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
18
+ { bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
19
+ { bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
20
+ { bin: "checks-quality", script: "quality.ts", reads: "tree", args: ["--check"], appliesTo: QUALITY_DECLARATION },
21
+ { bin: "checks-size-budget", script: "size-budget.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
22
+ { bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE }
23
+ ];
24
+ var UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
25
+ var LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin))).check(Schema.makeFilter((selected) => {
26
+ const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
27
+ if (missing.length === 0)
28
+ return true;
29
+ const verb = missing.length === 1 ? "applies" : "apply";
30
+ return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
31
+ }, { toJsonSchema: () => ({ allOf: UNCONDITIONAL.map((bin) => ({ contains: { const: bin } })) }) }));
32
+
33
+ // scripts/quality-file.ts
34
+ var SEGMENT = String.raw`(?!\.\.?(?:/|$))(?:\*\*|(?:[\w.@+-]|\*(?!\*))+)`;
35
+ var FILE = String.raw`(?:[\w.@+-]|\*(?!\*))*\.\w+`;
36
+ var PathGlob = Schema2.String.check(Schema2.isPattern(new RegExp(`^${SEGMENT}(?:/${SEGMENT})*/${FILE}$`), {
37
+ expected: "a glob from the repository root such as src/**/*.ts: a directory first, * within a segment, ** as a whole one, a file name with an extension last"
38
+ })).annotate({
39
+ identifier: "PathGlob",
40
+ description: "A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./"
41
+ });
42
+ var LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
43
+ var DirectoryPath = Schema2.String.check(Schema2.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
44
+ expected: "a directory from the repository root such as src/billing, with no glob and no trailing slash"
45
+ })).annotate({ identifier: "DirectoryPath" });
46
+ var FilePath = Schema2.String.check(Schema2.isPattern(new RegExp(`^(?:${LITERAL_SEGMENT}/)*[\\w.@+-]*\\.\\w+$`), {
47
+ expected: "a file from the repository root such as src/billing/index.ts, with no glob"
48
+ })).annotate({ identifier: "FilePath" });
49
+ var PROOF_DIRECTORY = "tests/e2e/";
50
+ var ProofPath = Schema2.String.check(Schema2.isPattern(new RegExp(`^${PROOF_DIRECTORY}(?:${LITERAL_SEGMENT}/)*[\\w.@+-]+\\.test\\.tsx?$`), {
51
+ expected: `a test file under ${PROOF_DIRECTORY} such as ${PROOF_DIRECTORY}billing.test.ts`
52
+ })).annotate({ identifier: "ProofPath" });
53
+ var Command = Schema2.NonEmptyString.annotate({ identifier: "Command" });
54
+ var RuleName = Schema2.String.check(Schema2.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a Rule name in kebab case" })).annotate({ identifier: "RuleName" });
55
+ var Identity = Schema2.Struct({ name: Schema2.NonEmptyString, email: Schema2.NonEmptyString }).annotate({
56
+ identifier: "Identity"
57
+ });
58
+ var CommitIdentity = Schema2.Struct({
59
+ authors: Schema2.NonEmptyArray(Identity).annotate({
60
+ description: "The identities allowed to author and commit, in place of the kit's default owner"
61
+ })
62
+ });
63
+ var Gates = Schema2.Struct({
64
+ ci: Schema2.optionalKey(Schema2.NonEmptyArray(Command).annotate({
65
+ description: "The commands CI runs on every pull request to the default branch, each one plain command"
66
+ })),
67
+ scheduled: Schema2.optionalKey(Schema2.Array(Command).annotate({ description: "The commands a cron-scheduled workflow runs" })),
68
+ lint: Schema2.optionalKey(LintGates)
69
+ });
70
+ var EffectSources = Schema2.Struct({
71
+ paths: Schema2.NonEmptyArray(PathGlob).annotate({
72
+ description: "Where source is written in Effect, held to the Effect rules of oxlint and the language service"
73
+ }),
74
+ exempt: Schema2.optionalKey(Schema2.Array(PathGlob).annotate({ description: "Files under paths the Effect rules pass over" }))
75
+ });
76
+ var Sources = Schema2.Struct({
77
+ production: Schema2.optionalKey(Schema2.Array(PathGlob).annotate({ description: "The source the repository ships, as against tests and tooling" })),
78
+ effect: Schema2.optionalKey(EffectSources)
79
+ });
80
+ var LineBudget = Schema2.Int.check(Schema2.isGreaterThan(0));
81
+ var Size = Schema2.Struct({
82
+ fileLines: LineBudget.annotate({ description: "The most lines a file may hold, blank and comment lines counted" }),
83
+ functionLines: LineBudget.annotate({
84
+ description: "The most lines a function may span, blank and comment lines counted"
85
+ }),
86
+ applies: Schema2.Literals(["changed", "all"]).annotate({
87
+ description: "Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory"
88
+ })
89
+ });
90
+ var Feature = Schema2.Struct({
91
+ name: Schema2.String.check(Schema2.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a feature name in kebab case" })).annotate({ description: "The owner the dependency rule and the change signal name" }),
92
+ root: DirectoryPath.annotate({ description: "The directory the feature owns" }),
93
+ entries: Schema2.NonEmptyArray(FilePath).annotate({
94
+ description: "The files under root that code outside it imports the feature through"
95
+ }),
96
+ allowFrom: Schema2.optionalKey(Schema2.Array(PathGlob).annotate({
97
+ description: "Files outside root that may import past its entries, such as a CLI or a harness; tests/ always may"
98
+ })),
99
+ proof: ProofPath.annotate({ description: "The end-to-end test that imports one of entries" })
100
+ }).check(Schema2.makeFilter(({ root, entries }) => {
101
+ const outside = entries.filter((entry) => !entry.startsWith(`${root}/`));
102
+ return outside.length === 0 || `lists ${outside.join(", ")} among its entries, outside its root ${root}`;
103
+ }));
104
+ function nests(outer, inner) {
105
+ return outer === inner || inner.startsWith(`${outer}/`);
106
+ }
107
+ var Features = Schema2.Array(Feature).check(Schema2.makeFilter((features) => {
108
+ const names = features.map((feature) => feature.name);
109
+ const repeated = names.filter((name, index) => names.indexOf(name) !== index);
110
+ if (repeated.length > 0)
111
+ return `names ${[...new Set(repeated)].join(", ")} more than once`;
112
+ for (const outer of features) {
113
+ const inner = features.find((other) => other !== outer && nests(outer.root, other.root));
114
+ if (inner !== undefined)
115
+ return `gives ${inner.root} to both ${outer.name} and ${inner.name}`;
116
+ }
117
+ return true;
118
+ }));
119
+ var AgentRules = Schema2.Struct({
120
+ on: Schema2.optionalKey(Schema2.Array(RuleName).annotate({ description: "Catalogued Rules switched on here" })),
121
+ off: Schema2.optionalKey(Schema2.Array(RuleName).annotate({ description: "Catalogued Rules switched off here" }))
122
+ }).check(Schema2.makeFilter(({ on = [], off = [] }) => {
123
+ const both = on.filter((rule) => off.includes(rule));
124
+ return both.length === 0 || `switches ${both.join(", ")} both on and off`;
125
+ }));
126
+ var Quality = Schema2.Struct({
127
+ $schema: Schema2.optionalKey(Schema2.String),
128
+ defaultBranch: Schema2.optionalKey(Schema2.NonEmptyString.annotate({ description: "The branch pull requests merge into; main when absent" })),
129
+ gates: Schema2.optionalKey(Gates),
130
+ commitIdentity: Schema2.optionalKey(CommitIdentity),
131
+ sources: Schema2.optionalKey(Sources),
132
+ size: Schema2.optionalKey(Size.annotate({ description: "The line budget oxlint holds production files to, read by checks-size-budget" })),
133
+ features: Schema2.optionalKey(Features.annotate({
134
+ description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to"
135
+ })),
136
+ changeSignal: Schema2.optionalKey(Schema2.Literal("advisory").annotate({
137
+ description: "Report which feature owners a change touches, without failing on it"
138
+ })),
139
+ agentRules: Schema2.optionalKey(AgentRules)
140
+ }).annotate({
141
+ title: QUALITY_FILE,
142
+ description: "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection"
143
+ }).check(Schema2.makeFilter(({ size, sources }) => size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds nothing without sources.production", {
144
+ toJsonSchema: () => ({
145
+ if: { required: ["size"] },
146
+ then: { required: ["sources"], properties: { sources: { required: ["production"], properties: { production: { minItems: 1 } } } } }
147
+ })
148
+ }), Schema2.makeFilter(({ changeSignal, features = [] }) => changeSignal === undefined || features.length > 0 || "declares changeSignal, which maps a change to no owner without features", {
149
+ toJsonSchema: () => ({
150
+ if: { required: ["changeSignal"] },
151
+ then: { required: ["features"], properties: { features: { minItems: 1 } } }
152
+ })
153
+ }));
154
+ var LegacyManifest = Schema2.Struct({
155
+ ciWiring: Schema2.optionalKey(Schema2.Struct({
156
+ gates: Schema2.optionalKey(Schema2.NonEmptyArray(Command)),
157
+ scheduled: Schema2.optionalKey(Schema2.Array(Command)),
158
+ lintGates: Schema2.optionalKey(LintGates),
159
+ defaultBranch: Schema2.optionalKey(Schema2.NonEmptyString)
160
+ })),
161
+ commitIdentity: Schema2.optionalKey(CommitIdentity)
162
+ });
163
+ var LEGACY_KEYS = ["ciWiring", "commitIdentity"];
164
+
165
+ class QualityUnreadable extends Schema2.TaggedError()("QualityUnreadable", {
166
+ message: Schema2.String
167
+ }) {
168
+ }
169
+ var MANIFEST = "package.json";
170
+ var decodeQualityJson = Schema2.decodeUnknownEffect(Schema2.fromJsonString(Quality), { onExcessProperty: "error" });
171
+ var decodeManifestJson = Schema2.decodeUnknownEffect(Schema2.fromJsonString(LegacyManifest));
172
+ var decodeQuality = (text, source) => decodeQualityJson(text).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
173
+ function fromLegacy({ ciWiring, commitIdentity }) {
174
+ const gates = {
175
+ ...ciWiring?.gates === undefined ? {} : { ci: ciWiring.gates },
176
+ ...ciWiring?.scheduled === undefined ? {} : { scheduled: ciWiring.scheduled },
177
+ ...ciWiring?.lintGates === undefined ? {} : { lint: ciWiring.lintGates }
178
+ };
179
+ return {
180
+ ...ciWiring?.defaultBranch === undefined ? {} : { defaultBranch: ciWiring.defaultBranch },
181
+ ...Object.keys(gates).length === 0 ? {} : { gates },
182
+ ...commitIdentity === undefined ? {} : { commitIdentity }
183
+ };
184
+ }
185
+ var decodeManifest = (text, source) => decodeManifestJson(text).pipe(Effect.map((manifest) => ({
186
+ keys: LEGACY_KEYS.filter((key) => manifest[key] !== undefined),
187
+ quality: fromLegacy(manifest)
188
+ })), Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
189
+ var UNDECLARED = { keys: [], quality: {} };
190
+ var readQuality = Effect.fn("readQuality")(function* (root) {
191
+ const fs = yield* FileSystem.FileSystem;
192
+ const path = yield* Path.Path;
193
+ const read = (file) => fs.readFileString(path.join(root, file)).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `cannot read ${file}: ${cause.message}` })));
194
+ const legacy = (yield* fs.exists(path.join(root, MANIFEST))) ? yield* decodeManifest(yield* read(MANIFEST), MANIFEST) : UNDECLARED;
195
+ const keys = legacy.keys.join(" and ");
196
+ if (yield* fs.exists(path.join(root, QUALITY_FILE))) {
197
+ if (legacy.keys.length > 0) {
198
+ return yield* new QualityUnreadable({
199
+ message: `${MANIFEST} still sets ${keys}, which ${QUALITY_FILE} replaces; move what it holds there`
200
+ });
201
+ }
202
+ return { source: QUALITY_FILE, quality: yield* decodeQuality(yield* read(QUALITY_FILE), QUALITY_FILE) };
203
+ }
204
+ if (legacy.keys.length > 0) {
205
+ const them = legacy.keys.length === 1 ? "it" : "them";
206
+ yield* Console.error(`${MANIFEST} sets ${keys}, which a later minor release stops reading; move ${them} into ${QUALITY_FILE}`);
207
+ }
208
+ return { source: MANIFEST, quality: legacy.quality };
209
+ });
210
+
211
+ // scripts/feature-rules.ts
212
+ var TESTS = "^tests/";
213
+ function escaped(literal) {
214
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
215
+ }
216
+ function globPattern(glob) {
217
+ const segments = glob.split("/");
218
+ const body = segments.map((segment, index) => {
219
+ if (segment === "**")
220
+ return "(?:[^/]+/)*";
221
+ const pattern = segment.split("*").map(escaped).join("[^/]*");
222
+ return index === segments.length - 1 ? pattern : `${pattern}/`;
223
+ });
224
+ return `^${body.join("")}$`;
225
+ }
226
+ function rulesFor(features) {
227
+ return features.map(({ name, root, entries, allowFrom = [] }) => ({
228
+ name: `feature-${name}-entries`,
229
+ severity: "error",
230
+ comment: `Outside ${root}/, ${name} is imported through ${entries.join(", ")}. Import one of those, or list the importer in the feature's allowFrom in quality.json.`,
231
+ from: { pathNot: [`^${escaped(root)}/`, TESTS, ...allowFrom.map(globPattern)] },
232
+ to: { path: `^${escaped(root)}/`, pathNot: entries.map((entry) => `^${escaped(entry)}$`) }
233
+ }));
234
+ }
235
+ var decode = Schema3.decodeUnknownSync(Quality);
236
+ function featureRules(quality) {
237
+ return rulesFor(decode(quality, { onExcessProperty: "error" }).features ?? []);
238
+ }
239
+ export {
240
+ globPattern,
241
+ featureRules
242
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,10 +32,18 @@
32
32
  "scripts/comment-gate.ts",
33
33
  "scripts/suppressions-ratchet.ts",
34
34
  "scripts/backtest.ts",
35
+ "scripts/quality-file.ts",
36
+ "scripts/quality.ts",
37
+ "scripts/size-budget.ts",
38
+ "scripts/feature-owners.ts",
39
+ "presets/effect.oxlint.json",
40
+ "presets/effect.language-service.json",
41
+ "quality.schema.json",
35
42
  "oxlintrc.json",
36
43
  "stryker.preset.js",
37
44
  "tsconfig.effect.json",
38
- "dist/index.js"
45
+ "dist/index.js",
46
+ "dist/feature-rules.js"
39
47
  ],
40
48
  "exports": {
41
49
  "./bunfig.toml": "./bunfig.toml",
@@ -54,10 +62,18 @@
54
62
  "./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
55
63
  "./scripts/suppressions-ratchet.ts": "./scripts/suppressions-ratchet.ts",
56
64
  "./scripts/backtest.ts": "./scripts/backtest.ts",
65
+ "./scripts/quality-file.ts": "./scripts/quality-file.ts",
66
+ "./scripts/quality.ts": "./scripts/quality.ts",
67
+ "./scripts/size-budget.ts": "./scripts/size-budget.ts",
68
+ "./scripts/feature-owners.ts": "./scripts/feature-owners.ts",
69
+ "./presets/effect.oxlint.json": "./presets/effect.oxlint.json",
70
+ "./presets/effect.language-service.json": "./presets/effect.language-service.json",
71
+ "./quality.schema.json": "./quality.schema.json",
57
72
  "./oxlintrc.json": "./oxlintrc.json",
58
73
  "./stryker.preset.js": "./stryker.preset.js",
59
74
  "./tsconfig.effect.json": "./tsconfig.effect.json",
60
- "./dist/index.js": "./dist/index.js"
75
+ "./dist/index.js": "./dist/index.js",
76
+ "./dist/feature-rules.js": "./dist/feature-rules.js"
61
77
  },
62
78
  "bin": {
63
79
  "checks-lint": "scripts/lint.ts",
@@ -70,27 +86,17 @@
70
86
  "checks-ci-wiring": "scripts/ci-wiring.ts",
71
87
  "checks-comment-gate": "scripts/comment-gate.ts",
72
88
  "checks-suppressions-ratchet": "scripts/suppressions-ratchet.ts",
73
- "checks-backtest": "scripts/backtest.ts"
89
+ "checks-backtest": "scripts/backtest.ts",
90
+ "checks-quality": "scripts/quality.ts",
91
+ "checks-size-budget": "scripts/size-budget.ts",
92
+ "checks-feature-owners": "scripts/feature-owners.ts"
74
93
  },
75
94
  "scripts": {
76
- "build": "bun build effect-channel/index.ts --outdir dist --target node --format esm",
95
+ "build": "bun build effect-channel/index.ts --outdir dist --target node --format esm && bun build scripts/feature-rules.ts --outdir dist --target node --format esm --packages external && bun scripts/quality-schema.ts",
77
96
  "lint": "oxlint --type-aware && bun scripts/lint.ts && depcruise --config .dependency-cruiser.cjs .",
78
97
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
79
98
  "test": "bun scripts/test.ts"
80
99
  },
81
- "ciWiring": {
82
- "gates": [
83
- "bun run build",
84
- "git diff --exit-code dist/",
85
- "bun run lint",
86
- "bun run typecheck",
87
- "bun run test",
88
- "./node_modules/.bin/commitlint"
89
- ],
90
- "scheduled": [
91
- "bun scripts/flake.ts --runs 10 --report flake-report.json"
92
- ]
93
- },
94
100
  "peerDependencies": {
95
101
  "@swc/core": "1.16.2",
96
102
  "dependency-cruiser": "18.4.0",
@@ -104,6 +110,7 @@
104
110
  "@oxlint/plugins": "1.83.0",
105
111
  "@swc/core": "1.16.2",
106
112
  "@types/bun": "^1.3.0",
113
+ "ajv": "8.20.0",
107
114
  "dependency-cruiser": "18.4.0",
108
115
  "effect": "4.0.0-rc.115",
109
116
  "oxlint": "1.83.0",
@@ -0,0 +1,8 @@
1
+ {
2
+ "diagnosticSeverity": {
3
+ "nodeBuiltinImport": "error",
4
+ "asyncFunction": "error",
5
+ "newPromise": "error",
6
+ "extendsNativeError": "error"
7
+ }
8
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "plugins": ["node", "promise", "unicorn"],
3
+ "rules": {
4
+ "node/no-sync": "error",
5
+ "oxc/no-async-await": "error",
6
+ "promise/avoid-new": "error",
7
+ "unicorn/no-process-exit": "error",
8
+ "effect-channel/no-throw": "error",
9
+ "effect-channel/no-try-catch": "error"
10
+ }
11
+ }