typescript-bedrock 0.1.3 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typescript-bedrock",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Strictest Bun + TypeScript 7 tooling: shared configs, git hooks, a guard, and an adopt command.",
5
5
  "license": "MIT",
6
6
  "author": "xsyetopz",
@@ -1,8 +1,8 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { basename, join } from "node:path";
3
3
 
4
- import { mergeBunfig } from "./bunfig.ts";
5
- import { asRecord, exists, type Json, readJsonc, toJson } from "./json.ts";
4
+ import { copies, existing, planBunfig } from "./copies.ts";
5
+ import { asRecord, type Json, readJsonc, toJson } from "./json.ts";
6
6
  import { migrateManifest } from "./manifest.ts";
7
7
  import { sharedSteps } from "./shared.ts";
8
8
  import { pending, type Step } from "./steps.ts";
@@ -100,7 +100,8 @@ export const BASE_FILES = [
100
100
  ".markdownlint.jsonc",
101
101
  "justfile",
102
102
  ];
103
- /** Thin files that point at typescript-bedrock. A changed copy is backed up, then replaced. */
103
+ /** Thin files that point at typescript-bedrock. A changed copy is backed up, then replaced,
104
+ * except the entries a repo adds to the ignore list of `.oxlintrc.json` or `.markdownlint-cli2.jsonc`, which stay. */
104
105
  const TOOL_FILES = [
105
106
  ".bun-version",
106
107
  ".github/workflows/typescript.yml",
@@ -114,46 +115,6 @@ const STARTER_FILES = [".vscode/extensions.json", ".vscode/settings.json"];
114
115
  const OLD_CI = ".github/workflows/ci.yml";
115
116
  const OLD_CI_COMMAND = /\b(?:npm|npx|pnpm|yarn)\b|bun run verify/u;
116
117
 
117
- const existing = async (
118
- root: string,
119
- paths: readonly string[],
120
- ): Promise<string[]> => {
121
- const found = await Promise.all(
122
- paths.map((path) => exists(join(root, path))),
123
- );
124
- return paths.filter((_, index) => found[index] === true);
125
- };
126
-
127
- /** npm packs drop `bunfig.toml`, and linters read nested configs, so these keep a suffix. */
128
- const STORED_WITH_SUFFIX = new Set([
129
- ".markdownlint-cli2.jsonc",
130
- ".oxlintrc.json",
131
- "bunfig.toml",
132
- ]);
133
- const storedPath = (path: string): string =>
134
- STORED_WITH_SUFFIX.has(path) ? `${path}.template` : path;
135
-
136
- const copyFile = async (dir: string, path: string): Promise<Step> => ({
137
- kind: "write",
138
- path,
139
- content: await Bun.file(join(dir, storedPath(path))).text(),
140
- executable: path.startsWith(".githooks/"),
141
- });
142
-
143
- const copies = async (
144
- dir: string,
145
- target: string,
146
- paths: readonly string[],
147
- overwrite: boolean,
148
- ): Promise<Step[]> => {
149
- const present = new Set(overwrite ? [] : await existing(target, paths));
150
- return Promise.all(
151
- paths
152
- .filter((path) => !present.has(path))
153
- .map((path) => copyFile(dir, path)),
154
- );
155
- };
156
-
157
118
  const planManifest = async (
158
119
  packageRoot: string,
159
120
  target: string,
@@ -176,21 +137,6 @@ const planManifest = async (
176
137
  );
177
138
  };
178
139
 
179
- const planBunfig = async (
180
- filesRoot: string,
181
- target: string,
182
- ): Promise<{ steps: Step[]; notes: string[] }> => {
183
- const file = Bun.file(join(target, "bunfig.toml"));
184
- const merged = mergeBunfig(
185
- await Bun.file(join(filesRoot, storedPath("bunfig.toml"))).text(),
186
- (await file.exists()) ? await file.text() : undefined,
187
- );
188
- return {
189
- steps: [{ kind: "write", path: "bunfig.toml", content: merged.content }],
190
- notes: merged.notes,
191
- };
192
- };
193
-
194
140
  const planTsconfig = async (
195
141
  packageRoot: string,
196
142
  target: string,
@@ -0,0 +1,92 @@
1
+ import { join } from "node:path";
2
+
3
+ import { mergeBunfig } from "./bunfig.ts";
4
+ import { IGNORE_KEYS, mergeIgnoreList } from "./ignore-lists.ts";
5
+ import { exists, readJsonc } from "./json.ts";
6
+ import type { Step } from "./steps.ts";
7
+
8
+ /** The paths under `root` that exist. */
9
+ export const existing = async (
10
+ root: string,
11
+ paths: readonly string[],
12
+ ): Promise<string[]> => {
13
+ const found = await Promise.all(
14
+ paths.map((path) => exists(join(root, path))),
15
+ );
16
+ return paths.filter((_, index) => found[index] === true);
17
+ };
18
+
19
+ /** npm packs drop `bunfig.toml`, and linters read nested configs, so these keep a suffix. */
20
+ const STORED_WITH_SUFFIX = new Set([
21
+ ".markdownlint-cli2.jsonc",
22
+ ".oxlintrc.json",
23
+ "bunfig.toml",
24
+ ]);
25
+ export const storedPath = (path: string): string =>
26
+ STORED_WITH_SUFFIX.has(path) ? `${path}.template` : path;
27
+
28
+ /** The template's text, plus the ignore entries the repo's copy adds to it. */
29
+ const contentOf = async (
30
+ dir: string,
31
+ target: string,
32
+ path: string,
33
+ ): Promise<string> => {
34
+ const template = await Bun.file(join(dir, storedPath(path))).text();
35
+ const key = IGNORE_KEYS[path];
36
+ return key === undefined
37
+ ? template
38
+ : mergeIgnoreList(template, await readJsonc(join(target, path)), key);
39
+ };
40
+
41
+ const copyFile = async (
42
+ dir: string,
43
+ target: string,
44
+ path: string,
45
+ ): Promise<Step[]> => {
46
+ const content = await contentOf(dir, target, path);
47
+ // A repo's own layout of its ignore list stays when the data matches.
48
+ if (
49
+ path in IGNORE_KEYS &&
50
+ Bun.deepEquals(
51
+ Bun.JSONC.parse(content),
52
+ await readJsonc(join(target, path)),
53
+ )
54
+ ) {
55
+ return [];
56
+ }
57
+ return [
58
+ { kind: "write", path, content, executable: path.startsWith(".githooks/") },
59
+ ];
60
+ };
61
+
62
+ /** Writes each of `paths` from `dir`; with `overwrite` off, only the ones `target` lacks. */
63
+ export const copies = async (
64
+ dir: string,
65
+ target: string,
66
+ paths: readonly string[],
67
+ overwrite: boolean,
68
+ ): Promise<Step[]> => {
69
+ const present = new Set(overwrite ? [] : await existing(target, paths));
70
+ const steps = await Promise.all(
71
+ paths
72
+ .filter((path) => !present.has(path))
73
+ .map((path) => copyFile(dir, target, path)),
74
+ );
75
+ return steps.flat();
76
+ };
77
+
78
+ /** The template's `bunfig.toml` plus the settings the repo's copy adds. */
79
+ export const planBunfig = async (
80
+ filesRoot: string,
81
+ target: string,
82
+ ): Promise<{ steps: Step[]; notes: string[] }> => {
83
+ const file = Bun.file(join(target, "bunfig.toml"));
84
+ const merged = mergeBunfig(
85
+ await Bun.file(join(filesRoot, storedPath("bunfig.toml"))).text(),
86
+ (await file.exists()) ? await file.text() : undefined,
87
+ );
88
+ return {
89
+ steps: [{ kind: "write", path: "bunfig.toml", content: merged.content }],
90
+ notes: merged.notes,
91
+ };
92
+ };
@@ -0,0 +1,41 @@
1
+ import { asRecord, asStrings } from "./json.ts";
2
+
3
+ /** The ignore list each tool config keeps, which a repo may extend. */
4
+ export const IGNORE_KEYS: Readonly<Record<string, string>> = {
5
+ ".markdownlint-cli2.jsonc": "ignores",
6
+ ".oxlintrc.json": "ignorePatterns",
7
+ };
8
+
9
+ const arrayOf = (key: string): RegExp =>
10
+ new RegExp(`("${key}"\\s*:\\s*)\\[([^\\]]*)\\]`, "u");
11
+
12
+ /**
13
+ * Writes the template's config with the entries `current` lists under `key`
14
+ * and the template lacks added after the template's own, in the template's
15
+ * layout and with its comments.
16
+ */
17
+ export const mergeIgnoreList = (
18
+ template: string,
19
+ current: unknown,
20
+ key: string,
21
+ ): string => {
22
+ const found = arrayOf(key).exec(template);
23
+ if (found === null) {
24
+ return template;
25
+ }
26
+ const have = new Set(asStrings(Bun.JSONC.parse(`[${found[2] ?? ""}]`)));
27
+ const extra = [
28
+ ...new Set(asStrings(asRecord(current)[key]).filter((e) => !have.has(e))),
29
+ ];
30
+ if (extra.length === 0) {
31
+ return template;
32
+ }
33
+ const items = [...have, ...extra].map((entry) => JSON.stringify(entry));
34
+ const body = (found[2] ?? "").includes("\n")
35
+ ? `\n${items.map((item) => `\t\t${item}`).join(",\n")}\n\t`
36
+ : items.join(", ");
37
+ return template.replace(
38
+ arrayOf(key),
39
+ (_, head: string) => `${head}[${body}]`,
40
+ );
41
+ };
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
 
3
3
  import { listFiles } from "../files.ts";
4
+ import { readBunfigTestIgnores } from "../ignores.ts";
4
5
  import type { Step } from "./steps.ts";
5
6
 
6
7
  const TEST_FILES = new Bun.Glob(
@@ -40,9 +41,16 @@ export const migrateTestFile = (text: string): string => {
40
41
  : text;
41
42
  };
42
43
 
43
- /** Rewrites each test file outside node_modules and git's ignored files that is not on bun:test yet. */
44
+ /**
45
+ * Rewrites each test file that is not on bun:test yet, except those in node_modules,
46
+ * those git ignores, and those bunfig.toml keeps `bun test` from running.
47
+ */
44
48
  export const testSteps = async (target: string): Promise<Step[]> => {
45
- const paths = await listFiles(target, TEST_FILES);
49
+ const paths = await listFiles(
50
+ target,
51
+ TEST_FILES,
52
+ await readBunfigTestIgnores(target),
53
+ );
46
54
  const texts = await Promise.all(
47
55
  paths.map((path) => Bun.file(join(target, path)).text()),
48
56
  );
package/src/files.ts CHANGED
@@ -2,8 +2,17 @@ import { join } from "node:path";
2
2
 
3
3
  import { $, type Glob } from "bun";
4
4
 
5
- /** The files under `cwd` that match `glob`, without the ones git ignores and without node_modules. */
6
- export const listFiles = async (cwd: string, glob: Glob): Promise<string[]> => {
5
+ import { isIgnored } from "./ignores.ts";
6
+
7
+ /**
8
+ * The files under `cwd` that match `glob`, without the ones git ignores, the
9
+ * ones `ignore` matches, and node_modules.
10
+ */
11
+ export const listFiles = async (
12
+ cwd: string,
13
+ glob: Glob,
14
+ ignore: readonly string[] = [],
15
+ ): Promise<string[]> => {
7
16
  const git = await $`git ls-files --cached --others --exclude-standard`
8
17
  .cwd(cwd)
9
18
  .nothrow()
@@ -13,7 +22,10 @@ export const listFiles = async (cwd: string, glob: Glob): Promise<string[]> => {
13
22
  ? git.stdout.toString().split("\n")
14
23
  : await Array.fromAsync(glob.scan({ cwd }))
15
24
  ).filter(
16
- (path) => glob.match(path) && !path.split("/").includes("node_modules"),
25
+ (path) =>
26
+ glob.match(path) &&
27
+ !path.split("/").includes("node_modules") &&
28
+ !isIgnored(path, ignore),
17
29
  );
18
30
  // The index still lists a file deleted since the last commit.
19
31
  const present = await Promise.all(
package/src/guard.ts CHANGED
@@ -5,6 +5,7 @@ import { $, Glob } from "bun";
5
5
 
6
6
  import { planAdoption, PROJECT_FILES } from "./adopt/adopt.ts";
7
7
  import { listFiles } from "./files.ts";
8
+ import { readOxlintIgnores } from "./ignores.ts";
8
9
 
9
10
  // Written as alternations so this file does not match itself.
10
11
  const SUPPRESSION =
@@ -181,9 +182,12 @@ const reExported = (
181
182
  * The tracked source files each package exposes: its entry points and, through
182
183
  * relative re-exports, the files they re-export from.
183
184
  */
184
- const listApiFiles = async (cwd: string): Promise<string[]> => {
185
- const sources = new Set(await listFiles(cwd, SOURCE));
186
- const manifests = await listFiles(cwd, MANIFEST);
185
+ const listApiFiles = async (
186
+ cwd: string,
187
+ ignore: readonly string[],
188
+ ): Promise<string[]> => {
189
+ const sources = new Set(await listFiles(cwd, SOURCE, ignore));
190
+ const manifests = await listFiles(cwd, MANIFEST, ignore);
187
191
  const entries = await Promise.all(
188
192
  manifests.map(async (path) => {
189
193
  const manifest: unknown = await Bun.file(join(cwd, path)).json();
@@ -215,8 +219,11 @@ const listApiFiles = async (cwd: string): Promise<string[]> => {
215
219
  };
216
220
 
217
221
  /** Exported declarations in the package API that lack a JSDoc comment. */
218
- const findUndocumentedApi = async (cwd: string): Promise<string[]> => {
219
- const files = await listApiFiles(cwd);
222
+ const findUndocumentedApi = async (
223
+ cwd: string,
224
+ ignore: readonly string[],
225
+ ): Promise<string[]> => {
226
+ const files = await listApiFiles(cwd, ignore);
220
227
  if (files.length === 0) {
221
228
  return [];
222
229
  }
@@ -255,15 +262,18 @@ const findDrift = async (cwd: string): Promise<string[]> => {
255
262
  );
256
263
  };
257
264
 
258
- export const findProblems = async (cwd: string): Promise<string[]> => [
259
- ...(await findSuppressions(cwd, await listFiles(cwd, SOURCE))),
260
- ...(await findUndocumentedApi(cwd)),
261
- ...(await findForeignFiles(cwd)),
262
- ...(await findForeignScripts(cwd)),
263
- ...(await findTypescriptDrift(cwd)),
264
- ...findUnsortedCatalogs(await Bun.file(join(cwd, "package.json")).json()),
265
- ...(await findDrift(cwd)),
266
- ];
265
+ export const findProblems = async (cwd: string): Promise<string[]> => {
266
+ const ignore = await readOxlintIgnores(cwd);
267
+ return [
268
+ ...(await findSuppressions(cwd, await listFiles(cwd, SOURCE, ignore))),
269
+ ...(await findUndocumentedApi(cwd, ignore)),
270
+ ...(await findForeignFiles(cwd)),
271
+ ...(await findForeignScripts(cwd)),
272
+ ...(await findTypescriptDrift(cwd)),
273
+ ...findUnsortedCatalogs(await Bun.file(join(cwd, "package.json")).json()),
274
+ ...(await findDrift(cwd)),
275
+ ];
276
+ };
267
277
 
268
278
  export const guard = async (cwd: string): Promise<number> => {
269
279
  const problems = await findProblems(cwd);
package/src/ignores.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { join } from "node:path";
2
+
3
+ import { Glob } from "bun";
4
+
5
+ import { asRecord, asStrings, readJsonc } from "./adopt/json.ts";
6
+
7
+ /** The globs a gitignore-style `pattern` stands for: the path itself and what is under it. */
8
+ const globsOf = (pattern: string): string[] => {
9
+ const dir = pattern.replace(/\/+$/u, "");
10
+ const anchored = dir.startsWith("/") || dir.includes("/", 1);
11
+ const base = dir.replace(/^\//u, "");
12
+ const glob = anchored ? base : `**/${base}`;
13
+ return [glob, `${glob}/**`];
14
+ };
15
+
16
+ /**
17
+ * Whether `path`, relative to the repo root, matches `patterns`: gitignore-style
18
+ * entries (a bare name matches at any depth, `dir/` matches a directory, a
19
+ * later `!entry` re-includes) that Bun's glob syntax can read.
20
+ */
21
+ export const isIgnored = (
22
+ path: string,
23
+ patterns: readonly string[],
24
+ ): boolean => {
25
+ let ignored = false;
26
+ for (const entry of patterns) {
27
+ const negated = entry.startsWith("!");
28
+ const pattern = negated ? entry.slice(1) : entry;
29
+ if (
30
+ pattern !== "" &&
31
+ globsOf(pattern).some((glob) => new Glob(glob).match(path))
32
+ ) {
33
+ ignored = !negated;
34
+ }
35
+ }
36
+ return ignored;
37
+ };
38
+
39
+ /** The `ignorePatterns` of the repo's `.oxlintrc.json`, which the repo may extend. */
40
+ export const readOxlintIgnores = async (cwd: string): Promise<string[]> =>
41
+ asStrings(
42
+ asRecord(await readJsonc(join(cwd, ".oxlintrc.json")))["ignorePatterns"],
43
+ );
44
+
45
+ /** The `[test] pathIgnorePatterns` of the repo's `bunfig.toml`: the paths `bun test` skips. */
46
+ export const readBunfigTestIgnores = async (cwd: string): Promise<string[]> => {
47
+ const file = Bun.file(join(cwd, "bunfig.toml"));
48
+ if (!(await file.exists())) {
49
+ return [];
50
+ }
51
+ const patterns = asRecord(
52
+ asRecord(Bun.TOML.parse(await file.text()))["test"],
53
+ )["pathIgnorePatterns"];
54
+ return typeof patterns === "string" ? [patterns] : asStrings(patterns);
55
+ };