@avi2dg/checks 0.52.0 → 0.53.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 +5 -566
- package/docs/bins.md +4 -0
- package/docs/configs/commit-messages.md +5 -4
- package/docs/design.md +28 -6
- package/docs/gates/checks-ci-wiring.md +1 -1
- package/docs/gates/checks-commit-identity.md +1 -1
- package/docs/gates/checks-commit-title.md +5 -4
- package/docs/gates/checks-copy.md +2 -2
- package/docs/gates/checks-night-run.md +4 -4
- package/docs/gates/checks-pr-text.md +90 -0
- package/docs/gates/checks-release-notes.md +6 -4
- package/docs/gates/checks-release-pr.md +3 -4
- package/docs/gates/checks-release-publish.md +63 -0
- package/docs/gates/checks-release-push.md +153 -0
- package/docs/gates/checks-release-report.md +2 -1
- package/docs/gates/checks-release-tag.md +2 -0
- package/docs/gates/checks-secrets.md +90 -1
- package/docs/gates/checks-setup.md +76 -0
- package/docs/gates/checks-vendor.md +3 -3
- package/package.json +12 -1
- package/src/delivery/commit-title.ts +14 -7
- package/src/delivery/gitleaks.ts +24 -9
- package/src/delivery/pr-text.ts +77 -0
- package/src/delivery/release-commit.ts +152 -0
- package/src/delivery/release-pr.ts +9 -132
- package/src/delivery/release-publish.ts +73 -0
- package/src/delivery/release-push.ts +148 -0
- package/src/delivery/release-tag.ts +4 -53
- package/src/delivery/release-tagging.ts +74 -0
- package/src/delivery/secrets-pack.ts +133 -0
- package/src/delivery/secrets.ts +5 -4
- package/src/delivery/setup.ts +399 -0
- package/src/delivery/workflow-references.ts +2 -2
- package/src/quality/comment-matchers.ts +1 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Config, ConfigProvider, Effect, FileSystem, Option, Path, Predicate, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
const PACK_VARIABLE = "CHECKS_SECRETS_PACK";
|
|
4
|
+
|
|
5
|
+
const OCTET_BITS = 8;
|
|
6
|
+
const OCTET_VALUES = 256;
|
|
7
|
+
const ADDRESS_BITS = 32;
|
|
8
|
+
const ANY_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
|
|
9
|
+
const IPV4_RANGE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/;
|
|
10
|
+
|
|
11
|
+
class PackUnreadable extends Schema.TaggedError<PackUnreadable>()("PackUnreadable", {
|
|
12
|
+
message: Schema.String,
|
|
13
|
+
}) {}
|
|
14
|
+
|
|
15
|
+
type Ipv4Range = { readonly octets: readonly number[]; readonly prefix: number };
|
|
16
|
+
|
|
17
|
+
function ipv4RangeOf(text: string): Option.Option<Ipv4Range> {
|
|
18
|
+
const match = IPV4_RANGE.exec(text);
|
|
19
|
+
if (match === null) return Option.none();
|
|
20
|
+
const [, ...groups] = match;
|
|
21
|
+
const octets = groups.slice(0, 4).map(Number);
|
|
22
|
+
const prefix = groups[4] === undefined ? ADDRESS_BITS : Number(groups[4]);
|
|
23
|
+
if (octets.some((octet) => octet >= OCTET_VALUES) || prefix > ADDRESS_BITS) return Option.none();
|
|
24
|
+
return Option.some({ octets, prefix });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A term reaches gitleaks inside a TOML string, which refuses a raw control character.
|
|
28
|
+
const Term = Schema.String.check(Schema.makeFilter((term: string) => (/\S/.test(term) && !/\p{Cc}/u.test(term)) || "is blank or holds a control character"));
|
|
29
|
+
|
|
30
|
+
const IpRange = Schema.String.check(Schema.makeFilter((text: string) => Option.isSome(ipv4RangeOf(text)) || "is not an IPv4 address or CIDR range"));
|
|
31
|
+
|
|
32
|
+
const Terms = Schema.optionalKey(Schema.Array(Term));
|
|
33
|
+
|
|
34
|
+
const Pack = Schema.Struct({
|
|
35
|
+
names: Terms,
|
|
36
|
+
domains: Terms,
|
|
37
|
+
hosts: Terms,
|
|
38
|
+
runners: Terms,
|
|
39
|
+
ipRanges: Schema.optionalKey(Schema.Array(IpRange)),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
type Pack = typeof Pack.Type;
|
|
43
|
+
|
|
44
|
+
const decodePack = Schema.decodeUnknownEffect(Schema.fromJsonString(Pack));
|
|
45
|
+
|
|
46
|
+
type Rule = { readonly id: string; readonly description: string };
|
|
47
|
+
|
|
48
|
+
const TERM_RULES = [
|
|
49
|
+
["names", { id: "private-name", description: "A name form the private pack lists" }],
|
|
50
|
+
["domains", { id: "private-domain", description: "A domain the private pack lists" }],
|
|
51
|
+
["hosts", { id: "private-host", description: "A host name the private pack lists" }],
|
|
52
|
+
["runners", { id: "private-runner", description: "A runner name the private pack lists" }],
|
|
53
|
+
] as const satisfies readonly (readonly [Exclude<keyof Pack, "ipRanges">, Rule])[];
|
|
54
|
+
|
|
55
|
+
const IP_RANGE_RULE: Rule = { id: "private-ip-range", description: "An address in an IP range the private pack lists" };
|
|
56
|
+
|
|
57
|
+
function escaped(text: string): string {
|
|
58
|
+
return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// RE2's \b is an ASCII word boundary, so it never holds beside a non-ASCII letter or a symbol.
|
|
62
|
+
function boundaryAt(character: string | undefined): string {
|
|
63
|
+
return character !== undefined && /\w/.test(character) ? "\\b" : "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function termPattern(term: string): string {
|
|
67
|
+
const trimmed = term.trim();
|
|
68
|
+
return `${boundaryAt(trimmed[0])}${trimmed.split(/[\s\p{Zs}]+/u).map(escaped).join("[\\s\\p{Zs}]+")}${boundaryAt(trimmed.at(-1))}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function octetPattern(octet: number, fixedBits: number): string {
|
|
72
|
+
if (fixedBits === OCTET_BITS) return String(octet);
|
|
73
|
+
if (fixedBits === 0) return ANY_OCTET;
|
|
74
|
+
const span = 2 ** (OCTET_BITS - fixedBits);
|
|
75
|
+
const low = octet - (octet % span);
|
|
76
|
+
return `(?:${Array.from({ length: span }, (_, step) => String(low + step)).join("|")})`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ipRangePattern({ octets, prefix }: Ipv4Range): string {
|
|
80
|
+
const fixedBits = (index: number) => Math.max(0, Math.min(OCTET_BITS, prefix - index * OCTET_BITS));
|
|
81
|
+
return `\\b${octets.map((octet, index) => octetPattern(octet, fixedBits(index))).join("\\.")}\\b`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// JSON writes each string as a TOML basic string reads it, escapes included.
|
|
85
|
+
function ruleToml({ id, description }: Rule, patterns: readonly string[]): readonly string[] {
|
|
86
|
+
if (patterns.length === 0) return [];
|
|
87
|
+
const regex = `(?i)(?:${patterns.join("|")})`;
|
|
88
|
+
return [["[[rules]]", `id = ${JSON.stringify(id)}`, `description = ${JSON.stringify(description)}`, `regex = ${JSON.stringify(regex)}`].join("\n")];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function packRules(pack: Pack): string {
|
|
92
|
+
const ranges = (pack.ipRanges ?? []).flatMap((text) => Option.toArray(ipv4RangeOf(text)));
|
|
93
|
+
return [
|
|
94
|
+
...TERM_RULES.flatMap(([kind, rule]) => ruleToml(rule, (pack[kind] ?? []).map(termPattern))),
|
|
95
|
+
...ruleToml(IP_RANGE_RULE, ranges.map(ipRangePattern)),
|
|
96
|
+
].join("\n\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The path can carry a user name and the pack carries the terms, so no message here names either.
|
|
100
|
+
const packRulesAt = Effect.fn("packRulesAt")(function* (file: string) {
|
|
101
|
+
const unreadable = (why: string) => new PackUnreadable({ message: `the private pack ${PACK_VARIABLE} names ${why}, so the gate cannot scan for its terms` });
|
|
102
|
+
if (file === "") return yield* new PackUnreadable({ message: `${PACK_VARIABLE} is set but empty, so it names no private pack and the gate cannot scan for its terms` });
|
|
103
|
+
const read = yield* (yield* FileSystem.FileSystem).readFileString(file).pipe(
|
|
104
|
+
Effect.mapError((cause) => unreadable(Predicate.isTagged(cause.reason, "NotFound") ? "no file that exists" : "a file the gate cannot read")),
|
|
105
|
+
);
|
|
106
|
+
const pack = yield* decodePack(read, { onExcessProperty: "error" }).pipe(
|
|
107
|
+
Effect.mapError(() => unreadable("is not the JSON object docs/gates/checks-secrets.md describes")),
|
|
108
|
+
);
|
|
109
|
+
const rules = packRules(pack);
|
|
110
|
+
if (rules === "") return yield* unreadable("lists no term");
|
|
111
|
+
return rules;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
type ScanConfig = { readonly config: string; readonly notice: string };
|
|
115
|
+
|
|
116
|
+
export const packConfig = Effect.fn("packConfig")(function* (name: string, scratch: string, file: Option.Option<string>) {
|
|
117
|
+
const path = yield* Path.Path;
|
|
118
|
+
const kitConfig = path.join(import.meta.dir, "gitleaks.toml");
|
|
119
|
+
if (Option.isNone(file)) {
|
|
120
|
+
return { config: kitConfig, notice: `${name}: ${PACK_VARIABLE} is unset, so the kit's rules ran without a private pack` } satisfies ScanConfig;
|
|
121
|
+
}
|
|
122
|
+
const rules = yield* packRulesAt(file.value);
|
|
123
|
+
const fs = yield* FileSystem.FileSystem;
|
|
124
|
+
const config = path.join(scratch, "gitleaks.toml");
|
|
125
|
+
yield* fs.writeFileString(config, `${yield* fs.readFileString(kitConfig)}\n${rules}\n`);
|
|
126
|
+
return { config, notice: `${name}: the kit's rules and the private pack ${PACK_VARIABLE} names ran` } satisfies ScanConfig;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export const scanConfig = Effect.fn("scanConfig")(function* (name: string, scratch: string) {
|
|
130
|
+
// Config reads an empty value as unset, and a CI secret that is missing expands to one, so the pack would silently drop.
|
|
131
|
+
const file = yield* Config.option(Config.String(PACK_VARIABLE)).parse(ConfigProvider.fromEnv({ preserveEmptyStrings: true }));
|
|
132
|
+
return yield* packConfig(name, scratch, file);
|
|
133
|
+
});
|
package/src/delivery/secrets.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { Console, Effect, FileSystem,
|
|
2
|
+
import { Console, Effect, FileSystem, Schema } from "effect";
|
|
3
3
|
import { commitOf, git, refArgs } from "../core/git.ts";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
5
|
import { cacheRoot } from "../dependencies/cache-root.ts";
|
|
6
6
|
import { pinnedGitleaks, scanCommits, type Leak } from "./gitleaks.ts";
|
|
7
|
+
import { scanConfig } from "./secrets-pack.ts";
|
|
7
8
|
|
|
8
9
|
const NAME = "secrets";
|
|
9
10
|
const USAGE = "usage: secrets.ts <ref> | <base-ref> <head-ref>";
|
|
@@ -70,16 +71,16 @@ export function report(leaks: readonly Leak[]): string {
|
|
|
70
71
|
|
|
71
72
|
const secrets = Effect.gen(function* () {
|
|
72
73
|
const fs = yield* FileSystem.FileSystem;
|
|
73
|
-
const path = yield* Path.Path;
|
|
74
74
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
75
75
|
const logOptions = yield* logOptionsOf(process.argv.slice(2), root);
|
|
76
|
-
const binary = yield* pinnedGitleaks(yield* cacheRoot());
|
|
77
|
-
const config = path.join(import.meta.dir, "gitleaks.toml");
|
|
78
76
|
const ignoreDir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-" });
|
|
77
|
+
const { config, notice } = yield* scanConfig(NAME, yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-pack-" }));
|
|
78
|
+
const binary = yield* pinnedGitleaks(yield* cacheRoot());
|
|
79
79
|
const gitDir = (yield* git(["rev-parse", "--absolute-git-dir"], root)).trim();
|
|
80
80
|
const reported = yield* scanCommits({ binary, gitDir, logOptions, config, ignoreDir });
|
|
81
81
|
const leaks = yield* Effect.forEach(reported, (leak) => atRepositoryPath(leak, root));
|
|
82
82
|
yield* Console.log(report(leaks));
|
|
83
|
+
yield* Console.log(notice);
|
|
83
84
|
return leaks.length === 0;
|
|
84
85
|
}).pipe(Effect.scoped);
|
|
85
86
|
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import { runMain, Usage } from "../core/main.ts";
|
|
4
|
+
import { TEMPLATES } from "../docs/doc-templates.ts";
|
|
5
|
+
import { scriptOptions } from "../testing/test.ts";
|
|
6
|
+
import { withServiceOverrides, type ServiceOverride } from "../quality/effect-scope.ts";
|
|
7
|
+
import { SOURCES, TESTS } from "../quality/presets/oxlint.ts";
|
|
8
|
+
import severities from "../quality/presets/effect.language-service.json" with { type: "json" };
|
|
9
|
+
|
|
10
|
+
const NAME = "checks-setup";
|
|
11
|
+
const USAGE = "usage: " + NAME + " [--private]";
|
|
12
|
+
const PRIVATE = "--private";
|
|
13
|
+
const HOSTED_FALLBACK = "ubuntu-latest";
|
|
14
|
+
const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
|
|
15
|
+
const IGNORE_ENTRY = "node_modules/";
|
|
16
|
+
const KIT_ID = "@avi2dg/checks";
|
|
17
|
+
|
|
18
|
+
class SetupError extends Schema.TaggedError<SetupError>()("SetupError", {
|
|
19
|
+
message: Schema.String,
|
|
20
|
+
}) {}
|
|
21
|
+
|
|
22
|
+
const KitManifest = Schema.Struct({ version: Schema.String });
|
|
23
|
+
const decodeKitManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(KitManifest));
|
|
24
|
+
|
|
25
|
+
const JsonRecord = Schema.Record(Schema.String, Schema.Unknown);
|
|
26
|
+
const decodeRecord = Schema.decodeUnknownEffect(JsonRecord);
|
|
27
|
+
|
|
28
|
+
const DEFAULT_SCRIPTS: Record<string, string> = {
|
|
29
|
+
lint: "oxlint && checks-lint",
|
|
30
|
+
typecheck: "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
|
|
31
|
+
test: "checks-test",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const OXLINT_CONFIG = [
|
|
35
|
+
"import { defineConfig } from \"@avi2dg/checks/oxlint\";",
|
|
36
|
+
"",
|
|
37
|
+
"export default defineConfig({ effect: true });",
|
|
38
|
+
"",
|
|
39
|
+
].join("\n");
|
|
40
|
+
|
|
41
|
+
const TSCONFIG_BASE = [
|
|
42
|
+
"{",
|
|
43
|
+
" \"extends\": \"@avi2dg/checks/tsconfig.effect.json\",",
|
|
44
|
+
" \"compilerOptions\": { \"types\": [\"bun\"] }",
|
|
45
|
+
"}",
|
|
46
|
+
"",
|
|
47
|
+
].join("\n");
|
|
48
|
+
|
|
49
|
+
const DEFAULT_OVERRIDES: readonly ServiceOverride[] = [
|
|
50
|
+
{ include: [...SOURCES], exclude: [...TESTS], options: severities },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function quotedStrings(text: string): readonly string[] {
|
|
54
|
+
const found: string[] = [];
|
|
55
|
+
const pattern = new RegExp("\"((?:[^\"\\\\]|\\\\.)*)\"|'((?:[^'\\\\]|\\\\.)*)'", "g");
|
|
56
|
+
for (;;) {
|
|
57
|
+
const match = pattern.exec(text);
|
|
58
|
+
if (match === null) return found;
|
|
59
|
+
found.push(match[1] ?? match[2] ?? "");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function delimitedInner(text: string, at: number, openChar: string, closeChar: string): string | undefined {
|
|
64
|
+
const open = text.indexOf(openChar, at);
|
|
65
|
+
if (open === -1) return undefined;
|
|
66
|
+
let depth = 0;
|
|
67
|
+
let quote: string | undefined;
|
|
68
|
+
for (let index = open; index < text.length; index += 1) {
|
|
69
|
+
const char = text[index];
|
|
70
|
+
if (quote !== undefined) {
|
|
71
|
+
if (char === "\\") index += 1;
|
|
72
|
+
else if (char === quote) quote = undefined;
|
|
73
|
+
} else if (char === "\"" || char === "'") {
|
|
74
|
+
quote = char;
|
|
75
|
+
} else if (char === openChar) {
|
|
76
|
+
depth += 1;
|
|
77
|
+
} else if (char === closeChar) {
|
|
78
|
+
depth -= 1;
|
|
79
|
+
if (depth === 0) return text.slice(open + 1, index);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function arrayAfter(text: string, key: string): readonly string[] | undefined {
|
|
86
|
+
const at = text.search(new RegExp(key + "\\s*:"));
|
|
87
|
+
if (at === -1) return undefined;
|
|
88
|
+
const inner = delimitedInner(text, at, "[", "]");
|
|
89
|
+
if (inner === undefined) return undefined;
|
|
90
|
+
return quotedStrings(inner);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function blockAfter(text: string, at: number): string | undefined {
|
|
94
|
+
return delimitedInner(text, at, "{", "}");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const effectScopeOf = (text: string): Effect.Effect<readonly ServiceOverride[], SetupError> => {
|
|
98
|
+
const match = new RegExp("effect\\s*:\\s*(true|false|\\{)").exec(text);
|
|
99
|
+
if (match === null || match[1] === "true") return Effect.succeed(DEFAULT_OVERRIDES);
|
|
100
|
+
if (match[1] === "false") return Effect.succeed([]);
|
|
101
|
+
const block = blockAfter(text, match.index);
|
|
102
|
+
if (block === undefined) return Effect.succeed(DEFAULT_OVERRIDES);
|
|
103
|
+
const files = arrayAfter(block, "files");
|
|
104
|
+
const excludeFiles = arrayAfter(block, "excludeFiles") ?? [];
|
|
105
|
+
if (files !== undefined && files.length === 0) {
|
|
106
|
+
return Effect.fail(new SetupError({ message: "cannot read the effect scope of oxlint.config.ts, give files at least one glob or remove the file so setup writes a fresh config" }));
|
|
107
|
+
}
|
|
108
|
+
if (files === undefined) return Effect.succeed([{ include: [...SOURCES], exclude: [...TESTS, ...excludeFiles], options: severities }]);
|
|
109
|
+
return Effect.succeed([{ include: files, exclude: excludeFiles, options: severities }]);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const KNIP_CONFIG = [
|
|
113
|
+
"import { defineConfig } from \"@avi2dg/checks/knip\";",
|
|
114
|
+
"",
|
|
115
|
+
"export default defineConfig({ entry: [\"src/index.ts\"] });",
|
|
116
|
+
"",
|
|
117
|
+
].join("\n");
|
|
118
|
+
|
|
119
|
+
function workflow(isPrivate: boolean, hasBuild: boolean): string {
|
|
120
|
+
const lines = [
|
|
121
|
+
"name: ci",
|
|
122
|
+
"",
|
|
123
|
+
"on:",
|
|
124
|
+
" pull_request:",
|
|
125
|
+
" types: [opened, synchronize]",
|
|
126
|
+
"",
|
|
127
|
+
"jobs:",
|
|
128
|
+
" checks:",
|
|
129
|
+
" runs-on: " + (isPrivate ? HOSTED_DEFAULT : HOSTED_FALLBACK),
|
|
130
|
+
" steps:",
|
|
131
|
+
" - uses: actions/checkout@v5",
|
|
132
|
+
" with:",
|
|
133
|
+
" fetch-depth: 0",
|
|
134
|
+
" - uses: oven-sh/setup-bun@v2",
|
|
135
|
+
" with:",
|
|
136
|
+
" bun-version-file: .bun-version",
|
|
137
|
+
" - run: bun install --frozen-lockfile",
|
|
138
|
+
];
|
|
139
|
+
if (hasBuild) {
|
|
140
|
+
lines.push(" - run: bun run build", " - run: git diff --exit-code");
|
|
141
|
+
}
|
|
142
|
+
lines.push(" - run: bun run lint", " - run: bun run typecheck", " - run: bun run test");
|
|
143
|
+
lines.push(
|
|
144
|
+
" - id: title",
|
|
145
|
+
" name: lint the title",
|
|
146
|
+
" env:",
|
|
147
|
+
" PR_TITLE: ${{ github.event.pull_request.title }}",
|
|
148
|
+
" GH_TOKEN: ${{ github.token }}",
|
|
149
|
+
" run: bunx checks-commit-title",
|
|
150
|
+
"",
|
|
151
|
+
);
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const kitText = Effect.fn("kitText")(function* (file: string) {
|
|
156
|
+
const system = yield* FileSystem.FileSystem;
|
|
157
|
+
const path = yield* Path.Path;
|
|
158
|
+
const full = path.join(path.join(import.meta.dir, "..", ".."), file);
|
|
159
|
+
return yield* system.readFileString(full).pipe(
|
|
160
|
+
Effect.mapError((cause) => new SetupError({ message: "cannot read the kit " + file + " at " + full + ": " + cause.message })),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const setupFile = Effect.fn("setupFile")(function* (root: string, relative: string, content: string) {
|
|
165
|
+
const system = yield* FileSystem.FileSystem;
|
|
166
|
+
const path = yield* Path.Path;
|
|
167
|
+
const full = path.join(root, relative);
|
|
168
|
+
if (yield* system.exists(full)) {
|
|
169
|
+
yield* Console.log(NAME + ": kept " + relative + ", already there");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
yield* system.makeDirectory(path.dirname(full), { recursive: true });
|
|
173
|
+
yield* system.writeFileString(full, content);
|
|
174
|
+
yield* Console.log(NAME + ": wrote " + relative);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const setupIgnore = Effect.fn("setupIgnore")(function* (root: string) {
|
|
178
|
+
const system = yield* FileSystem.FileSystem;
|
|
179
|
+
const full = (yield* Path.Path).join(root, ".gitignore");
|
|
180
|
+
if (!(yield* system.exists(full))) {
|
|
181
|
+
yield* system.writeFileString(full, IGNORE_ENTRY + "\n");
|
|
182
|
+
yield* Console.log(NAME + ": wrote .gitignore");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const text = yield* system.readFileString(full).pipe(
|
|
186
|
+
Effect.mapError((cause) => new SetupError({ message: "cannot read .gitignore: " + cause.message })),
|
|
187
|
+
);
|
|
188
|
+
if (text.split("\n").some((line) => line.trim() === IGNORE_ENTRY)) {
|
|
189
|
+
yield* Console.log(NAME + ": kept .gitignore, already there");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const end = text === "" || text.endsWith("\n") ? text : text + "\n";
|
|
193
|
+
yield* system.writeFileString(full, end + IGNORE_ENTRY + "\n");
|
|
194
|
+
yield* Console.log(NAME + ": added " + IGNORE_ENTRY + " to .gitignore");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
type MutableRecord = Record<string, unknown>;
|
|
198
|
+
|
|
199
|
+
const parseRecord = (
|
|
200
|
+
parse: () => unknown,
|
|
201
|
+
toParseFault: (cause: unknown) => SetupError,
|
|
202
|
+
shapeFault: SetupError,
|
|
203
|
+
): Effect.Effect<MutableRecord, SetupError> =>
|
|
204
|
+
Effect.try({ try: parse, catch: (cause) => toParseFault(cause) }).pipe(
|
|
205
|
+
Effect.flatMap((parsed) => decodeRecord(parsed).pipe(Effect.map((decoded) => ({ ...decoded })), Effect.mapError(() => shapeFault))),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const parseTsconfigRecord = (current: string): Effect.Effect<MutableRecord, SetupError> =>
|
|
209
|
+
parseRecord(
|
|
210
|
+
(): unknown => Bun.JSONC.parse(current),
|
|
211
|
+
() => new SetupError({ message: "cannot read tsconfig.json as JSONC, fix the file or remove it so setup writes a fresh config" }),
|
|
212
|
+
new SetupError({ message: "cannot read tsconfig.json as JSONC, fix the file or remove it so setup writes a fresh config: expected an object" }),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const setupTsconfig = Effect.fn("setupTsconfig")(function* (root: string, oxlintText: string) {
|
|
216
|
+
const system = yield* FileSystem.FileSystem;
|
|
217
|
+
const path = yield* Path.Path;
|
|
218
|
+
const full = path.join(root, "tsconfig.json");
|
|
219
|
+
const present = yield* system.exists(full);
|
|
220
|
+
const current = present
|
|
221
|
+
? yield* system.readFileString(full).pipe(Effect.mapError((cause) => new SetupError({ message: "cannot read tsconfig.json: " + cause.message })))
|
|
222
|
+
: TSCONFIG_BASE;
|
|
223
|
+
yield* parseTsconfigRecord(current);
|
|
224
|
+
const overrides = yield* effectScopeOf(oxlintText);
|
|
225
|
+
const next = withServiceOverrides(current, overrides);
|
|
226
|
+
if (present && next === current) {
|
|
227
|
+
yield* Console.log(NAME + ": kept tsconfig.json, already there");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!present) yield* system.makeDirectory(path.dirname(full), { recursive: true });
|
|
231
|
+
yield* system.writeFileString(full, next);
|
|
232
|
+
yield* Console.log(NAME + ": wrote tsconfig.json");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
function scriptFault(scripts: Record<string, unknown>): string | undefined {
|
|
236
|
+
const lint = scripts["lint"];
|
|
237
|
+
if (typeof lint === "string" && (!lint.includes("oxlint") || !lint.includes("checks-lint"))) {
|
|
238
|
+
return "package.json scripts.lint must run oxlint and the layout check: use \"oxlint && checks-lint\"";
|
|
239
|
+
}
|
|
240
|
+
const test = scripts["test"];
|
|
241
|
+
if (typeof test === "string") {
|
|
242
|
+
const options = scriptOptions(test);
|
|
243
|
+
if (options === undefined || options.tier !== undefined || options.full === true) {
|
|
244
|
+
return "package.json scripts.test must be \"checks-test\", optionally with --parallel=<workers>, found " + JSON.stringify(test);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const typecheck = scripts["typecheck"];
|
|
248
|
+
if (typeof typecheck === "string" && (!typecheck.includes("tsc") || !typecheck.includes("effect-tsgo"))) {
|
|
249
|
+
return "package.json scripts.typecheck must run tsc and the Effect diagnostics: use \"tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict\"";
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const parsePackageRecord = (raw: string): Effect.Effect<MutableRecord, SetupError> =>
|
|
255
|
+
parseRecord(
|
|
256
|
+
(): unknown => JSON.parse(raw),
|
|
257
|
+
(cause) => new SetupError({ message: "cannot read package.json as JSON, fix the file or remove it so setup writes a fresh manifest: " + String(cause) }),
|
|
258
|
+
new SetupError({ message: "cannot read package.json as JSON, fix the file or remove it so setup writes a fresh manifest: expected an object" }),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const fieldRecord = (record: MutableRecord, field: string, message: string): Effect.Effect<MutableRecord, SetupError> => {
|
|
262
|
+
const found = record[field];
|
|
263
|
+
if (found === undefined) return Effect.succeed({});
|
|
264
|
+
return decodeRecord(found).pipe(Effect.map((decoded) => ({ ...decoded })), Effect.mapError(() => new SetupError({ message })));
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const scriptsOf = (record: MutableRecord): Effect.Effect<MutableRecord, SetupError> => fieldRecord(record, "scripts", "cannot read package.json as JSON, fix the file or remove it so setup writes a fresh manifest: expected scripts to be an object");
|
|
268
|
+
|
|
269
|
+
const depsOf = (record: MutableRecord): Effect.Effect<MutableRecord, SetupError> => fieldRecord(record, "devDependencies", "cannot read package.json as JSON, fix the file or remove it so setup writes a fresh manifest: expected devDependencies to be an object");
|
|
270
|
+
|
|
271
|
+
function withKitDefaults(scripts: MutableRecord): MutableRecord {
|
|
272
|
+
const next: MutableRecord = { ...scripts };
|
|
273
|
+
for (const [name, command] of Object.entries(DEFAULT_SCRIPTS)) {
|
|
274
|
+
if (typeof next[name] !== "string") next[name] = command;
|
|
275
|
+
}
|
|
276
|
+
return next;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function finalizePackage(
|
|
280
|
+
record: MutableRecord,
|
|
281
|
+
scripts: MutableRecord,
|
|
282
|
+
devDependencies: MutableRecord,
|
|
283
|
+
isPrivate: boolean,
|
|
284
|
+
fallbackName: string,
|
|
285
|
+
): { readonly merged: MutableRecord; readonly hasBuild: boolean; readonly hasOwner: boolean } {
|
|
286
|
+
const merged: MutableRecord = { ...record, scripts, devDependencies };
|
|
287
|
+
if (typeof merged["name"] !== "string") merged["name"] = fallbackName;
|
|
288
|
+
if (merged["type"] === undefined) merged["type"] = "module";
|
|
289
|
+
if (isPrivate) merged["private"] = true;
|
|
290
|
+
const hasOwner = "author" in merged || "contributors" in merged;
|
|
291
|
+
const hasBuild = typeof scripts["build"] === "string";
|
|
292
|
+
return { merged, hasBuild, hasOwner };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const writeFreshPackage = Effect.fn("writeFreshPackage")(function* (
|
|
296
|
+
root: string,
|
|
297
|
+
isPrivate: boolean,
|
|
298
|
+
version: string,
|
|
299
|
+
fallbackName: string,
|
|
300
|
+
) {
|
|
301
|
+
const system = yield* FileSystem.FileSystem;
|
|
302
|
+
const path = yield* Path.Path;
|
|
303
|
+
const full = path.join(root, "package.json");
|
|
304
|
+
const manifest = {
|
|
305
|
+
name: fallbackName,
|
|
306
|
+
type: "module",
|
|
307
|
+
...(isPrivate ? { private: true } : {}),
|
|
308
|
+
scripts: { ...DEFAULT_SCRIPTS },
|
|
309
|
+
devDependencies: { [KIT_ID]: version },
|
|
310
|
+
};
|
|
311
|
+
yield* system.makeDirectory(path.dirname(full), { recursive: true });
|
|
312
|
+
yield* system.writeFileString(full, JSON.stringify(manifest, undefined, 2) + "\n");
|
|
313
|
+
yield* Console.log(NAME + ": wrote package.json");
|
|
314
|
+
return { hasBuild: false, hasOwner: false };
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const updatePackage = Effect.fn("updatePackage")(function* (
|
|
318
|
+
root: string,
|
|
319
|
+
raw: string,
|
|
320
|
+
record: MutableRecord,
|
|
321
|
+
isPrivate: boolean,
|
|
322
|
+
fallbackName: string,
|
|
323
|
+
version: string,
|
|
324
|
+
) {
|
|
325
|
+
const system = yield* FileSystem.FileSystem;
|
|
326
|
+
const full = (yield* Path.Path).join(root, "package.json");
|
|
327
|
+
const baseScripts = yield* scriptsOf(record);
|
|
328
|
+
const baseDeps = yield* depsOf(record);
|
|
329
|
+
const devDependencies: MutableRecord = { ...baseDeps, [KIT_ID]: version };
|
|
330
|
+
const fault = scriptFault(baseScripts);
|
|
331
|
+
if (fault !== undefined) return yield* new SetupError({ message: fault });
|
|
332
|
+
const scripts = withKitDefaults(baseScripts);
|
|
333
|
+
const finalized = finalizePackage(record, scripts, devDependencies, isPrivate, fallbackName);
|
|
334
|
+
const next = JSON.stringify(finalized.merged, undefined, 2) + "\n";
|
|
335
|
+
if (next === raw) {
|
|
336
|
+
yield* Console.log(NAME + ": kept package.json, already there");
|
|
337
|
+
return { hasBuild: finalized.hasBuild, hasOwner: finalized.hasOwner };
|
|
338
|
+
}
|
|
339
|
+
yield* system.writeFileString(full, next);
|
|
340
|
+
yield* Console.log(NAME + ": wrote package.json");
|
|
341
|
+
return { hasBuild: finalized.hasBuild, hasOwner: finalized.hasOwner };
|
|
342
|
+
});
|
|
343
|
+
const setupPackage = Effect.fn("setupPackage")(function* (
|
|
344
|
+
root: string,
|
|
345
|
+
isPrivate: boolean,
|
|
346
|
+
version: string,
|
|
347
|
+
fallbackName: string,
|
|
348
|
+
) {
|
|
349
|
+
const system = yield* FileSystem.FileSystem;
|
|
350
|
+
const full = (yield* Path.Path).join(root, "package.json");
|
|
351
|
+
if (!(yield* system.exists(full))) {
|
|
352
|
+
return yield* writeFreshPackage(root, isPrivate, version, fallbackName);
|
|
353
|
+
}
|
|
354
|
+
const raw = yield* system.readFileString(full).pipe(
|
|
355
|
+
Effect.mapError((cause) => new SetupError({ message: "cannot read package.json: " + cause.message })),
|
|
356
|
+
);
|
|
357
|
+
const record = yield* parsePackageRecord(raw);
|
|
358
|
+
return yield* updatePackage(root, raw, record, isPrivate, fallbackName, version);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const check = Effect.gen(function* () {
|
|
362
|
+
const args = process.argv.slice(2);
|
|
363
|
+
const unknown = args.find((arg) => arg !== PRIVATE);
|
|
364
|
+
if (unknown !== undefined) return yield* new Usage({ message: unknown + " is not an argument\n" + USAGE });
|
|
365
|
+
const isPrivate = args.includes(PRIVATE);
|
|
366
|
+
const root = yield* Effect.sync(() => process.cwd());
|
|
367
|
+
const path = yield* Path.Path;
|
|
368
|
+
|
|
369
|
+
const version = (yield* decodeKitManifest(yield* kitText("package.json")).pipe(
|
|
370
|
+
Effect.mapError((cause) => new SetupError({ message: "cannot read the kit version: " + cause.message })),
|
|
371
|
+
)).version;
|
|
372
|
+
const outcome = yield* setupPackage(root, isPrivate, version, path.basename(root));
|
|
373
|
+
|
|
374
|
+
yield* setupFile(root, ".bun-version", yield* kitText(".bun-version"));
|
|
375
|
+
yield* setupFile(root, "bunfig.toml", yield* kitText("bunfig.toml"));
|
|
376
|
+
yield* setupIgnore(root);
|
|
377
|
+
const oxlintFull = path.join(root, "oxlint.config.ts");
|
|
378
|
+
const system = yield* FileSystem.FileSystem;
|
|
379
|
+
const oxlintRetained = yield* system.exists(oxlintFull);
|
|
380
|
+
yield* setupFile(root, "oxlint.config.ts", OXLINT_CONFIG);
|
|
381
|
+
const oxlintText = oxlintRetained
|
|
382
|
+
? yield* system.readFileString(oxlintFull).pipe(
|
|
383
|
+
Effect.mapError((cause) => new SetupError({ message: "cannot read oxlint.config.ts: " + cause.message })),
|
|
384
|
+
)
|
|
385
|
+
: OXLINT_CONFIG;
|
|
386
|
+
yield* setupTsconfig(root, oxlintText);
|
|
387
|
+
yield* setupFile(root, "knip.config.ts", KNIP_CONFIG);
|
|
388
|
+
yield* setupFile(root, ".github/workflows/ci.yml", workflow(isPrivate, outcome.hasBuild));
|
|
389
|
+
for (const kind of Object.keys(TEMPLATES)) {
|
|
390
|
+
yield* setupFile(root, "templates/" + kind + ".md", yield* kitText("dist/templates/" + kind + ".md"));
|
|
391
|
+
}
|
|
392
|
+
if (isPrivate) yield* Console.log(NAME + ": set the CI_RUNS_ON variable to a self-hosted runner label so the new jobs land there");
|
|
393
|
+
if (!outcome.hasOwner) {
|
|
394
|
+
yield* Console.log(NAME + ": declare the repository owners in package.json author or contributors before the first commit");
|
|
395
|
+
}
|
|
396
|
+
return true;
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
if (import.meta.main) runMain(NAME, check);
|
|
@@ -38,8 +38,8 @@ type Locator = (keys: readonly Key[], needle: string, fallback: string) => strin
|
|
|
38
38
|
type Node = { readonly from: number; readonly to: number; readonly column: number };
|
|
39
39
|
|
|
40
40
|
const WORKFLOWS = ".github/workflows";
|
|
41
|
-
const RELEASE_BINS = ["checks-release-pr", "checks-release-tag"];
|
|
42
|
-
const RELEASE_SOURCES = ["release-pr.ts", "release-tag.ts"];
|
|
41
|
+
const RELEASE_BINS = ["checks-release-pr", "checks-release-push", "checks-release-tag"];
|
|
42
|
+
const RELEASE_SOURCES = ["release-pr.ts", "release-push.ts", "release-tag.ts"];
|
|
43
43
|
const REUSABLE_PREFIX = "./.github/workflows/";
|
|
44
44
|
// GitHub substitutes an expression into the script before the shell reads it.
|
|
45
45
|
const EXPRESSION = /\$\{\{[\s\S]*?\}\}/g;
|
|
@@ -274,7 +274,7 @@ const CHECKS: readonly Check[] = [
|
|
|
274
274
|
`carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
|
|
275
275
|
},
|
|
276
276
|
{
|
|
277
|
-
find: /\bADR[\s-]?\d+|\bdocs\/adr\b
|
|
277
|
+
find: /\bADR[\s-]?\d+|\bdocs\/adr\b|(?:^|[\s(["'`])#\d+\b|[\w.-]+\/[\w.-]+#\d+|\/(?:issues|pull)\/\d+/i,
|
|
278
278
|
refusal: (match) =>
|
|
279
279
|
`points at a record or a ticket (${JSON.stringify(match.trim())}). Drop the pointer: a record is reached by searching docs/adr, and the story of the change goes in the commit message`,
|
|
280
280
|
},
|