@avi2dg/checks 0.22.0 → 0.24.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 +20 -0
- package/CONTRIBUTING.md +6 -5
- package/README.md +4 -1
- package/dist/effect-channel/index.js +122 -0
- package/dist/{index.js → readability/index.js} +8 -120
- package/docs/configs/native-settings.md +2 -2
- package/docs/design.md +7 -2
- package/docs/gates/checks-changelog.md +57 -0
- package/docs/gates/checks-release-notes.md +141 -0
- package/docs/gates/checks-release-report.md +64 -0
- package/docs/gates/checks-repetition.md +1 -0
- package/docs/gates/checks-vendor.md +11 -2
- package/oxlintrc.json +1 -1
- package/package.json +18 -4
- package/scripts/changelog-write.ts +95 -0
- package/scripts/changelog.ts +110 -0
- package/scripts/release-notes.ts +37 -0
- package/scripts/release-report.ts +50 -0
- package/scripts/repetition.ts +14 -3
- package/scripts/vendor-args.ts +54 -0
- package/scripts/vendor.ts +36 -66
|
@@ -16,6 +16,7 @@ A file that repeats lines without a rise is advisory.
|
|
|
16
16
|
## What it reads
|
|
17
17
|
|
|
18
18
|
jscpd reads the head's `.jscpd.json` at both ends, so its `path` and `ignore` globs select the files to measure.
|
|
19
|
+
A `path` entry the base revision lacks reads as empty there, so a newly measured folder never fails the scan.
|
|
19
20
|
|
|
20
21
|
```json
|
|
21
22
|
{ "path": ["src"], "format": ["typescript"], "ignore": ["**/*.d.ts"] }
|
|
@@ -16,9 +16,12 @@ The clone is staged beside its cache entry and moves into place only once it is
|
|
|
16
16
|
Every later run verifies the link rather than trusting it, and does so without contacting the remote.
|
|
17
17
|
It confirms the tree sits on the recorded commit.
|
|
18
18
|
It confirms the manifest inside the tree still names the installed version.
|
|
19
|
-
It confirms no write bit
|
|
19
|
+
It confirms no write bit is left once it has frozen the tree and no write landed outside the recorded commit.
|
|
20
20
|
It never follows a link inside the tree, so no mode outside the cache is touched.
|
|
21
|
-
|
|
21
|
+
A tool that clears the read only mode of `repos/<name>` changes the tree's top directory through the link, as the GitHub Actions runner does when it empties `$RUNNER_TEMP`.
|
|
22
|
+
A run that finds an owner write bit strips it from the tree again, then confirms no write landed outside the recorded commit.
|
|
23
|
+
A group or other write bit is never stripped, and fails the run before it reads the tree's status.
|
|
24
|
+
Any other failed confirmation fails the run, and so does a write the tree still holds once it is read only again.
|
|
22
25
|
A failed library drops its `repos/<name>` link, so a reader falls back to `node_modules/<package>` rather than a tree the run could not vouch for.
|
|
23
26
|
A missing tag, an unknown installed version or a manifest naming another version fails it too.
|
|
24
27
|
A tag moved upstream after the first fetch is not followed, since a cached tree stays on its recorded commit.
|
|
@@ -68,6 +71,12 @@ checks-vendor: repos/effect still holds effect@4.0.0-rc.115, verified against it
|
|
|
68
71
|
|
|
69
72
|
A later run reports the link it kept.
|
|
70
73
|
|
|
74
|
+
```
|
|
75
|
+
checks-vendor: found 1 path writable by its owner, starting with /home/runner/.cache/avi2dg-checks/repos/github.com/Effect-TS/effect/effect@4.0.0-rc.115, and froze the tree again, so repos/effect still holds effect@4.0.0-rc.115, verified against its recorded commit
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A run that found an owner write bit reports how many paths carried one and the first of them.
|
|
79
|
+
|
|
71
80
|
## Wiring
|
|
72
81
|
|
|
73
82
|
A consuming repository runs it from its `prepare` script, so every install pins and verifies the trees.
|
package/oxlintrc.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"plugins": ["typescript", "oxc", "eslint", "import"],
|
|
3
|
-
"jsPlugins": ["./dist/index.js"],
|
|
3
|
+
"jsPlugins": ["./dist/effect-channel/index.js", "./dist/readability/index.js"],
|
|
4
4
|
"categories": { "correctness": "error", "suspicious": "error" },
|
|
5
5
|
"rules": {
|
|
6
6
|
"effect-channel/no-error-channel-escape": "error",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Deterministic checks shared across the captain's TypeScript repos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"scripts/git.ts",
|
|
26
26
|
"scripts/gates.ts",
|
|
27
27
|
"scripts/lint.ts",
|
|
28
|
+
"scripts/changelog.ts",
|
|
29
|
+
"scripts/changelog-write.ts",
|
|
30
|
+
"scripts/release-notes.ts",
|
|
31
|
+
"scripts/release-report.ts",
|
|
28
32
|
"scripts/test-layout.ts",
|
|
29
33
|
"scripts/test.ts",
|
|
30
34
|
"scripts/test-skips.ts",
|
|
@@ -47,6 +51,7 @@
|
|
|
47
51
|
"scripts/quarantine-clock.ts",
|
|
48
52
|
"scripts/docs.ts",
|
|
49
53
|
"scripts/vendor.ts",
|
|
54
|
+
"scripts/vendor-args.ts",
|
|
50
55
|
"scripts/doc-outline.ts",
|
|
51
56
|
"scripts/doc-rules.ts",
|
|
52
57
|
"scripts/doc-references.ts",
|
|
@@ -58,7 +63,8 @@
|
|
|
58
63
|
"oxlintrc.json",
|
|
59
64
|
"stryker.preset.js",
|
|
60
65
|
"tsconfig.effect.json",
|
|
61
|
-
"dist/index.js"
|
|
66
|
+
"dist/effect-channel/index.js",
|
|
67
|
+
"dist/readability/index.js"
|
|
62
68
|
],
|
|
63
69
|
"exports": {
|
|
64
70
|
"./bunfig.toml": "./bunfig.toml",
|
|
@@ -66,6 +72,10 @@
|
|
|
66
72
|
"./dependency-cruiser.config.js": "./dependency-cruiser.config.js",
|
|
67
73
|
"./scripts/lint-coverage.sh": "./scripts/lint-coverage.sh",
|
|
68
74
|
"./scripts/lint.ts": "./scripts/lint.ts",
|
|
75
|
+
"./scripts/changelog.ts": "./scripts/changelog.ts",
|
|
76
|
+
"./scripts/changelog-write.ts": "./scripts/changelog-write.ts",
|
|
77
|
+
"./scripts/release-notes.ts": "./scripts/release-notes.ts",
|
|
78
|
+
"./scripts/release-report.ts": "./scripts/release-report.ts",
|
|
69
79
|
"./scripts/test-layout.ts": "./scripts/test-layout.ts",
|
|
70
80
|
"./scripts/test.ts": "./scripts/test.ts",
|
|
71
81
|
"./scripts/test-skips.ts": "./scripts/test-skips.ts",
|
|
@@ -98,10 +108,14 @@
|
|
|
98
108
|
"./oxlintrc.json": "./oxlintrc.json",
|
|
99
109
|
"./stryker.preset.js": "./stryker.preset.js",
|
|
100
110
|
"./tsconfig.effect.json": "./tsconfig.effect.json",
|
|
101
|
-
"./dist/index.js": "./dist/index.js"
|
|
111
|
+
"./dist/effect-channel/index.js": "./dist/effect-channel/index.js",
|
|
112
|
+
"./dist/readability/index.js": "./dist/readability/index.js"
|
|
102
113
|
},
|
|
103
114
|
"bin": {
|
|
104
115
|
"checks-lint": "scripts/lint.ts",
|
|
116
|
+
"checks-changelog": "scripts/changelog-write.ts",
|
|
117
|
+
"checks-release-notes": "scripts/release-notes.ts",
|
|
118
|
+
"checks-release-report": "scripts/release-report.ts",
|
|
105
119
|
"checks-lint-coverage": "scripts/lint-coverage.sh",
|
|
106
120
|
"checks-test-layout": "scripts/test-layout.ts",
|
|
107
121
|
"checks-test": "scripts/test.ts",
|
|
@@ -120,7 +134,7 @@
|
|
|
120
134
|
},
|
|
121
135
|
"scripts": {
|
|
122
136
|
"prepare": "bun scripts/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
|
|
123
|
-
"build": "bun build effect-channel/index.ts --outdir dist --target node --format esm && bun scripts/doc-templates-write.ts && bun scripts/changelog-write.ts && bun scripts/doc-blocks-write.ts",
|
|
137
|
+
"build": "bun build effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build readability/index.ts --outdir dist/readability --target node --format esm && bun scripts/doc-templates-write.ts && bun scripts/changelog-write.ts && bun scripts/doc-blocks-write.ts",
|
|
124
138
|
"lint": "oxlint --type-aware && bun scripts/lint.ts && depcruise --config .dependency-cruiser.cjs .",
|
|
125
139
|
"typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
|
|
126
140
|
"test": "bun scripts/test.ts"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, DateTime, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import { cuts, releaseDates, renderChangelog, type Bump, type Cut, type Release } from "./changelog.ts";
|
|
4
|
+
import { git } from "./git.ts";
|
|
5
|
+
import { runMain } from "./main.ts";
|
|
6
|
+
|
|
7
|
+
const NAME = "checks-changelog";
|
|
8
|
+
const CHANGELOG = "CHANGELOG.md";
|
|
9
|
+
const MANIFEST = "package.json";
|
|
10
|
+
const FIELD = "\x1f";
|
|
11
|
+
const TAG_PREFIX = "v";
|
|
12
|
+
|
|
13
|
+
class ChangelogUnreadable extends Schema.TaggedError<ChangelogUnreadable>()("ChangelogUnreadable", {
|
|
14
|
+
message: Schema.String,
|
|
15
|
+
}) {}
|
|
16
|
+
|
|
17
|
+
const decodeManifestJson = Schema.decodeUnknownEffect(
|
|
18
|
+
Schema.fromJsonString(
|
|
19
|
+
Schema.Struct({
|
|
20
|
+
name: Schema.String,
|
|
21
|
+
version: Schema.String,
|
|
22
|
+
repository: Schema.optional(Schema.Union([Schema.String, Schema.Struct({ url: Schema.optional(Schema.String) })])),
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const decodeManifest = (text: string, source: string) =>
|
|
28
|
+
decodeManifestJson(text).pipe(Effect.mapError((cause) => new ChangelogUnreadable({ message: `${source}: ${cause.message}` })));
|
|
29
|
+
|
|
30
|
+
const today = DateTime.nowInCurrentZone.pipe(DateTime.withCurrentZoneLocal, Effect.map(DateTime.formatIsoDate));
|
|
31
|
+
|
|
32
|
+
function repositoryWebUrl(repository: string): string | undefined {
|
|
33
|
+
const url = repository
|
|
34
|
+
.replace(/^git\+/, "")
|
|
35
|
+
.replace(/\/$/, "")
|
|
36
|
+
.replace(/\.git$/, "");
|
|
37
|
+
return url.startsWith("https://") ? url : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const versionAt = Effect.fn("versionAt")(function* (root: string, sha: string) {
|
|
41
|
+
return (yield* decodeManifest(yield* git(["show", `${sha}:${MANIFEST}`], root), `${MANIFEST} at ${sha}`)).version;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const readBumps = Effect.fn("readBumps")(function* (root: string) {
|
|
45
|
+
const log = yield* git(
|
|
46
|
+
["log", "--topo-order", "--reverse", "--full-history", "--no-merges", '-G"version"', "--format=%H%x1f%as%x1f%P", "HEAD", "--", MANIFEST],
|
|
47
|
+
root,
|
|
48
|
+
);
|
|
49
|
+
const bumps: Bump[] = [];
|
|
50
|
+
for (const line of log.split("\n")) {
|
|
51
|
+
const [sha, date, parent] = line.split(FIELD);
|
|
52
|
+
if (sha === undefined || date === undefined || parent === undefined) continue;
|
|
53
|
+
const version = yield* versionAt(root, sha);
|
|
54
|
+
if (parent === "" || (yield* versionAt(root, parent)) !== version) bumps.push({ sha, version, date });
|
|
55
|
+
}
|
|
56
|
+
return bumps;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const readPublished = Effect.fn("readPublished")(function* (root: string) {
|
|
60
|
+
const tags = yield* git(["for-each-ref", "--format=%(refname:strip=2)", `refs/tags/${TAG_PREFIX}*`], root);
|
|
61
|
+
return new Set(tags.split("\n").flatMap((tag) => (tag.startsWith(TAG_PREFIX) ? [tag.slice(TAG_PREFIX.length)] : [])));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const subjectsOf = Effect.fn("subjectsOf")(function* (root: string, { version, date, through, after }: Cut) {
|
|
65
|
+
const log = yield* git(["log", "--topo-order", "--format=%s", through, "--not", ...after], root);
|
|
66
|
+
return { version, date, subjects: log.split("\n").filter((subject) => subject !== "") } satisfies Release;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const write = Effect.gen(function* () {
|
|
70
|
+
const fs = yield* FileSystem.FileSystem;
|
|
71
|
+
const path = yield* Path.Path;
|
|
72
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
73
|
+
if ((yield* git(["rev-parse", "--is-shallow-repository"], root)).trim() === "true") {
|
|
74
|
+
return yield* new ChangelogUnreadable({ message: "the checkout is shallow, so the releases reach back past its history; fetch all of it" });
|
|
75
|
+
}
|
|
76
|
+
const target = path.join(root, CHANGELOG);
|
|
77
|
+
const { name, version, repository } = yield* decodeManifest(yield* fs.readFileString(path.join(root, MANIFEST)), MANIFEST);
|
|
78
|
+
const url = typeof repository === "string" ? repository : repository?.url;
|
|
79
|
+
if (url === undefined) {
|
|
80
|
+
return yield* new ChangelogUnreadable({ message: `${MANIFEST} has no repository.url, which the changelog links each pull request under` });
|
|
81
|
+
}
|
|
82
|
+
const repositoryUrl = repositoryWebUrl(url);
|
|
83
|
+
if (repositoryUrl === undefined) {
|
|
84
|
+
return yield* new ChangelogUnreadable({ message: `${MANIFEST} repository ${url} is no https address, which the changelog links each pull request under` });
|
|
85
|
+
}
|
|
86
|
+
const recorded = (yield* fs.exists(target)) ? releaseDates(yield* fs.readFileString(target)) : new Map<string, string>();
|
|
87
|
+
const pending = version === (yield* versionAt(root, "HEAD")) ? undefined : { sha: "HEAD", version, date: yield* today };
|
|
88
|
+
const released = cuts(yield* readBumps(root), recorded, yield* readPublished(root), pending);
|
|
89
|
+
const found = (yield* Effect.forEach(released, (cut) => subjectsOf(root, cut))).toReversed();
|
|
90
|
+
yield* fs.writeFileString(target, renderChangelog(name, found, repositoryUrl));
|
|
91
|
+
yield* Console.log(`${NAME}: wrote ${found.length} release(s) to ${CHANGELOG}`);
|
|
92
|
+
return true;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (import.meta.main) runMain(NAME, write);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { firstText, parseOutline, VERSION } from "./doc-outline.ts";
|
|
2
|
+
import { RELEASED } from "./doc-rules.ts";
|
|
3
|
+
import { CHANGE_GROUPS, type ChangeGroup } from "./doc-templates.ts";
|
|
4
|
+
|
|
5
|
+
export type Bump = {
|
|
6
|
+
readonly sha: string;
|
|
7
|
+
readonly version: string;
|
|
8
|
+
readonly date: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type Cut = {
|
|
12
|
+
readonly version: string;
|
|
13
|
+
readonly date: string;
|
|
14
|
+
readonly through: string;
|
|
15
|
+
readonly after: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type Release = {
|
|
19
|
+
readonly version: string;
|
|
20
|
+
readonly date: string;
|
|
21
|
+
readonly subjects: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type Entry = {
|
|
25
|
+
readonly group: ChangeGroup;
|
|
26
|
+
readonly text: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const CONVENTIONAL = /^([a-z]+)(?:\(([^()]*)\))?(!)?: (\S.*)$/;
|
|
30
|
+
|
|
31
|
+
const GROUP_OF_TYPE = new Map<string, ChangeGroup>([
|
|
32
|
+
["feat", "Features"],
|
|
33
|
+
["fix", "Fixes"],
|
|
34
|
+
["perf", "Performance"],
|
|
35
|
+
["revert", "Reverts"],
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// Only the subject is linted, and a squash merge's body is its branch's commit messages,
|
|
39
|
+
// so a breaking change is read from the subject's `!` and never from a footer.
|
|
40
|
+
export function groupOf(subject: string): ChangeGroup | undefined {
|
|
41
|
+
const [, type = "", , breaking] = CONVENTIONAL.exec(subject) ?? [];
|
|
42
|
+
return breaking === "!" ? "Breaking changes" : GROUP_OF_TYPE.get(type);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function entryOf(subject: string, repositoryUrl: string): Entry | undefined {
|
|
46
|
+
const group = groupOf(subject);
|
|
47
|
+
if (group === undefined) return undefined;
|
|
48
|
+
const [, , scope = "", , description = ""] = CONVENTIONAL.exec(subject) ?? [];
|
|
49
|
+
const linkedDescription = description.replace(/ \(#(\d+)\)$/, (_, number: string) => ` [#${number}](${repositoryUrl}/pull/${number})`);
|
|
50
|
+
return { group, text: scope === "" ? linkedDescription : `**${scope}:** ${linkedDescription}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A bump not newer than the release before it is a revert: it cancels every release above the version it
|
|
54
|
+
// returns to, except a published one.
|
|
55
|
+
function standing(bumps: readonly Bump[], published: ReadonlySet<string>): readonly Bump[] {
|
|
56
|
+
return bumps.reduce<readonly Bump[]>((kept, bump) => {
|
|
57
|
+
const before = kept.at(-1);
|
|
58
|
+
if (before === undefined || Bun.semver.order(bump.version, before.version) === 1) return [...kept, bump];
|
|
59
|
+
return kept.filter(({ version }) => published.has(version) || Bun.semver.order(version, bump.version) !== 1);
|
|
60
|
+
}, []);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The changelog records what was released: a version older than the newest it lists and absent from it
|
|
64
|
+
// was never published, so its commits roll into the next release.
|
|
65
|
+
export function cuts(
|
|
66
|
+
bumps: readonly Bump[],
|
|
67
|
+
recorded: ReadonlyMap<string, string>,
|
|
68
|
+
published: ReadonlySet<string>,
|
|
69
|
+
pending: Bump | undefined,
|
|
70
|
+
): readonly Cut[] {
|
|
71
|
+
const newest = [...recorded.keys()].toSorted(Bun.semver.order).at(-1);
|
|
72
|
+
const candidates = standing(pending === undefined ? bumps : [...bumps, pending], published);
|
|
73
|
+
const released = candidates.filter(
|
|
74
|
+
({ version }, index) =>
|
|
75
|
+
index === candidates.length - 1 || newest === undefined || recorded.has(version) || Bun.semver.order(version, newest) !== -1,
|
|
76
|
+
);
|
|
77
|
+
return released.map(({ sha, version, date }, index) => ({
|
|
78
|
+
version,
|
|
79
|
+
date: recorded.get(version) ?? date,
|
|
80
|
+
through: sha,
|
|
81
|
+
after: released.slice(0, index).map((earlier) => earlier.sha),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderRelease({ version, date, subjects }: Release, repositoryUrl: string): readonly string[] {
|
|
86
|
+
const entries = subjects.flatMap((subject) => entryOf(subject, repositoryUrl) ?? []);
|
|
87
|
+
const groups = CHANGE_GROUPS.flatMap((group) => {
|
|
88
|
+
const listed = entries.filter((entry) => entry.group === group).map(({ text }) => `- ${text}`);
|
|
89
|
+
return listed.length === 0 ? [] : [`### ${group}`, listed.join("\n")];
|
|
90
|
+
});
|
|
91
|
+
return [`## ${version}`, `Released ${date}.`, ...groups];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function renderChangelog(name: string, found: readonly Release[], repositoryUrl: string): string {
|
|
95
|
+
const blocks = [
|
|
96
|
+
"# Changelog",
|
|
97
|
+
`Every release of \`${name}\`, newest first, written by the release from its conventional commits.`,
|
|
98
|
+
...found.flatMap((release) => renderRelease(release, repositoryUrl)),
|
|
99
|
+
];
|
|
100
|
+
return `${blocks.join("\n\n")}\n`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function releaseDates(text: string): ReadonlyMap<string, string> {
|
|
104
|
+
return new Map(
|
|
105
|
+
parseOutline(text).sections.flatMap(({ heading, body }) => {
|
|
106
|
+
const date = RELEASED.exec(firstText(body)?.text.trim() ?? "")?.[1];
|
|
107
|
+
return VERSION.test(heading.title) && date !== undefined ? [[heading.title, date] as const] : [];
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Effect, FileSystem, Schema } from "effect";
|
|
3
|
+
import { runMain, Usage } from "./main.ts";
|
|
4
|
+
|
|
5
|
+
export class ReleaseSectionUnavailable extends Schema.TaggedError<ReleaseSectionUnavailable>()("ReleaseSectionUnavailable", {
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
}) {}
|
|
8
|
+
|
|
9
|
+
export function extractReleaseNotes(changelog: string, version: string): Effect.Effect<string, ReleaseSectionUnavailable> {
|
|
10
|
+
const lines = changelog.split(/\r?\n/);
|
|
11
|
+
const heading = lines.indexOf(`## ${version}`);
|
|
12
|
+
if (heading === -1) return Effect.fail(new ReleaseSectionUnavailable({ message: `CHANGELOG.md has no section for ${version}` }));
|
|
13
|
+
|
|
14
|
+
const next = lines.findIndex((line, index) => index > heading && line.startsWith("## "));
|
|
15
|
+
const body = lines.slice(heading + 1, next === -1 ? lines.length : next);
|
|
16
|
+
let start = 0;
|
|
17
|
+
let end = body.length;
|
|
18
|
+
while (start < end && body[start]?.trim() === "") start += 1;
|
|
19
|
+
while (end > start && body[end - 1]?.trim() === "") end -= 1;
|
|
20
|
+
const notes = body.slice(start, end).join("\n");
|
|
21
|
+
return notes === ""
|
|
22
|
+
? Effect.fail(new ReleaseSectionUnavailable({ message: `CHANGELOG.md has an empty section for ${version}` }))
|
|
23
|
+
: Effect.succeed(notes);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const USAGE = "usage: release-notes.ts <version> <output>";
|
|
27
|
+
|
|
28
|
+
const releaseNotes = Effect.gen(function* () {
|
|
29
|
+
const [version, output, ...extra] = process.argv.slice(2);
|
|
30
|
+
if (version === undefined || output === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
31
|
+
const fs = yield* FileSystem.FileSystem;
|
|
32
|
+
const notes = yield* extractReleaseNotes(yield* fs.readFileString("CHANGELOG.md"), version);
|
|
33
|
+
yield* fs.writeFileString(output, notes);
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (import.meta.main) runMain("checks-release-notes", releaseNotes);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, Schema } from "effect";
|
|
3
|
+
import { groupOf } from "./changelog.ts";
|
|
4
|
+
import { git } from "./git.ts";
|
|
5
|
+
import { runMain, Usage } from "./main.ts";
|
|
6
|
+
|
|
7
|
+
export class ReleaseReportUnreadable extends Schema.TaggedError<ReleaseReportUnreadable>()("ReleaseReportUnreadable", {
|
|
8
|
+
message: Schema.String,
|
|
9
|
+
}) {}
|
|
10
|
+
|
|
11
|
+
const USAGE = "usage: release-report.ts";
|
|
12
|
+
const RELEASE_TAG = "v[0-9]*.[0-9]*.[0-9]*";
|
|
13
|
+
|
|
14
|
+
export function unreleasedOf(subjects: readonly string[]): readonly string[] {
|
|
15
|
+
return subjects.filter((subject) => groupOf(subject) !== undefined);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatReport(unreleased: readonly string[], since: string | undefined): string {
|
|
19
|
+
const where = since === undefined ? "with no tag yet" : `since ${since}`;
|
|
20
|
+
if (unreleased.length === 0) return `release-report: no unreleased changes ${where}`;
|
|
21
|
+
return [`release-report: ${unreleased.length} unreleased change(s) ${where}:`, ...unreleased.map((subject) => ` ${subject}`)].join("\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lastTag = Effect.fn("lastTag")(function* (root: string) {
|
|
25
|
+
const described = yield* git(["describe", "--tags", "--abbrev=0", `--match=${RELEASE_TAG}`, "HEAD"], root).pipe(
|
|
26
|
+
Effect.map((tag) => tag.trim()),
|
|
27
|
+
Effect.catchTag("GitFailure", () => Effect.succeed("")),
|
|
28
|
+
);
|
|
29
|
+
return described === "" ? undefined : described;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const subjectsSince = Effect.fn("subjectsSince")(function* (root: string, since: string | undefined) {
|
|
33
|
+
const range = since === undefined ? "HEAD" : `${since}..HEAD`;
|
|
34
|
+
const log = yield* git(["log", "--topo-order", "--no-merges", "--format=%s", range], root);
|
|
35
|
+
return log.split("\n").filter((subject) => subject !== "");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const report = Effect.gen(function* () {
|
|
39
|
+
if (process.argv.slice(2).length > 0) return yield* new Usage({ message: USAGE });
|
|
40
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
41
|
+
if ((yield* git(["rev-parse", "--is-shallow-repository"], root)).trim() === "true") {
|
|
42
|
+
return yield* new ReleaseReportUnreadable({ message: "the checkout is shallow, so the tag it sees may not be the last one; fetch all of it" });
|
|
43
|
+
}
|
|
44
|
+
const since = yield* lastTag(root);
|
|
45
|
+
const unreleased = unreleasedOf(yield* subjectsSince(root, since));
|
|
46
|
+
yield* Console.log(formatReport(unreleased, since));
|
|
47
|
+
return unreleased.length === 0;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (import.meta.main) runMain("checks-release-report", report);
|
package/scripts/repetition.ts
CHANGED
|
@@ -48,6 +48,10 @@ const decodeReport = Schema.decodeUnknownEffect(
|
|
|
48
48
|
),
|
|
49
49
|
);
|
|
50
50
|
|
|
51
|
+
const decodeConfigPaths = Schema.decodeUnknownEffect(
|
|
52
|
+
Schema.fromJsonString(Schema.Struct({ path: Schema.optional(Schema.Array(Schema.String)) })),
|
|
53
|
+
);
|
|
54
|
+
|
|
51
55
|
type Scan = { readonly sources: number; readonly clones: readonly Clone[] };
|
|
52
56
|
|
|
53
57
|
function fragmentOf(file: (name: string) => string, { name, start, end }: typeof Location.Type): Fragment {
|
|
@@ -66,8 +70,9 @@ export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, num
|
|
|
66
70
|
|
|
67
71
|
// jscpd reads files from disk, and the working tree need not hold the revision: a merge checkout or an uncommitted edit.
|
|
68
72
|
// Both ends scan under the head's config, so a changed file set never reads as a change in repetition.
|
|
73
|
+
// jscpd refuses a missing path, so a path in `emptied` the revision lacks is created empty.
|
|
69
74
|
const scan = Effect.fn("scan")(
|
|
70
|
-
function* (root: string, rev: string, config: string) {
|
|
75
|
+
function* (root: string, rev: string, config: string, emptied: readonly string[]) {
|
|
71
76
|
const files = yield* pathsAt(rev, [], root);
|
|
72
77
|
if (files.length === 0) return { sources: 0, clones: [] } satisfies Scan;
|
|
73
78
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -75,6 +80,9 @@ const scan = Effect.fn("scan")(
|
|
|
75
80
|
const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "checks-repetition-" });
|
|
76
81
|
const tree = yield* checkoutFiles(rev, files, scratch, root);
|
|
77
82
|
yield* fs.writeFileString(path.join(tree, CONFIG), config);
|
|
83
|
+
for (const absent of emptied.map((entry) => path.join(tree, entry))) {
|
|
84
|
+
if (!(yield* fs.exists(absent))) yield* fs.makeDirectory(absent, { recursive: true });
|
|
85
|
+
}
|
|
78
86
|
const output = path.join(scratch, "report");
|
|
79
87
|
const flags = ["--silent", "--no-tips", "--no-gitignore", "--absolute", "--reporters", "json", "--output", output];
|
|
80
88
|
const thresholds = ["--min-tokens", `${MIN_TOKENS}`, "--min-lines", `${MIN_LINES}`];
|
|
@@ -137,8 +145,11 @@ const runHold = Effect.fn("runHold")(function* (root: string, config: string, ba
|
|
|
137
145
|
const formerPath = new Map(
|
|
138
146
|
(yield* changedPaths(base, head, [], root)).flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from]] : [])),
|
|
139
147
|
);
|
|
140
|
-
const
|
|
141
|
-
|
|
148
|
+
const { path: configPaths = [] } = yield* decodeConfigPaths(config).pipe(
|
|
149
|
+
Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot read ${CONFIG}: ${cause.message}` })),
|
|
150
|
+
);
|
|
151
|
+
const before = repeatedLines((yield* scan(root, base, config, configPaths)).clones);
|
|
152
|
+
const { sources, clones } = yield* scan(root, head, config, []);
|
|
142
153
|
const after = repeatedLines(clones);
|
|
143
154
|
const rises = risesOf([...after.keys()], before, after, formerPath, clones);
|
|
144
155
|
return heldOf(sources, rises, after) satisfies Held;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { Usage } from "./main.ts";
|
|
3
|
+
|
|
4
|
+
export const NAME = "checks-vendor";
|
|
5
|
+
export const OPENER = "--library";
|
|
6
|
+
const USAGE = `usage: ${NAME} [--library <name> --package <package> --repository <remote> --tag <template> [--path <manifest>]]...`;
|
|
7
|
+
|
|
8
|
+
const Library = Schema.Struct({
|
|
9
|
+
name: Schema.String.check(Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)),
|
|
10
|
+
package: Schema.NonEmptyString,
|
|
11
|
+
repository: Schema.NonEmptyString,
|
|
12
|
+
tag: Schema.String.check(Schema.isPattern(/\{version\}/)),
|
|
13
|
+
path: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^(?:[\w.@+-]+\/)*[\w.@+-]+\.\w+$/))),
|
|
14
|
+
});
|
|
15
|
+
export type Library = typeof Library.Type;
|
|
16
|
+
|
|
17
|
+
const Libraries = Schema.Array(Library).check(
|
|
18
|
+
Schema.makeFilter((libraries) => {
|
|
19
|
+
const names = libraries.map(({ name }) => name);
|
|
20
|
+
const repeated = names.find((name, index) => names.indexOf(name) !== index);
|
|
21
|
+
return repeated === undefined || `--library ${repeated} appears more than once`;
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const FIELDS = new Map<string, keyof Library>([
|
|
26
|
+
["--package", "package"],
|
|
27
|
+
["--repository", "repository"],
|
|
28
|
+
["--tag", "tag"],
|
|
29
|
+
["--path", "path"],
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function misuse(message: string): Usage {
|
|
33
|
+
return new Usage({ message: `${message}\n${USAGE}` });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const librariesFrom = Effect.fn("librariesFrom")(function* (args: readonly string[]) {
|
|
37
|
+
const groups: Record<string, string>[] = [];
|
|
38
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
39
|
+
const flag = args[index] ?? "";
|
|
40
|
+
const value = args[index + 1];
|
|
41
|
+
if (value === undefined || value.startsWith("--")) return yield* misuse(`${flag} takes a value`);
|
|
42
|
+
if (flag === OPENER) {
|
|
43
|
+
groups.push({ name: value });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const field = FIELDS.get(flag);
|
|
47
|
+
const current = groups.at(-1);
|
|
48
|
+
if (field === undefined) return yield* misuse(`${flag} is not an argument`);
|
|
49
|
+
if (current === undefined) return yield* misuse(`${flag} comes before any ${OPENER}`);
|
|
50
|
+
if (field in current) return yield* misuse(`${flag} appears twice for ${OPENER} ${current["name"]}`);
|
|
51
|
+
current[field] = value;
|
|
52
|
+
}
|
|
53
|
+
return yield* Schema.decodeUnknownEffect(Libraries)(groups).pipe(Effect.mapError((cause) => misuse(cause.message)));
|
|
54
|
+
});
|