typescript-bedrock 0.1.0 → 0.1.3

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.
@@ -8,10 +8,11 @@ mod? typescript ".just/typescript.just"
8
8
  mod? rust ".just/rust.just"
9
9
  mod? swift ".just/swift.just"
10
10
  mod? cpp ".just/cpp.just"
11
+ mod? jvm ".just/jvm.just"
11
12
 
12
13
  import? "project.just"
13
14
 
14
- languages := "typescript rust swift cpp"
15
+ languages := "typescript rust swift cpp jvm"
15
16
 
16
17
  # List the recipes.
17
18
  default:
@@ -24,7 +24,7 @@ coverage = true
24
24
  coverageSkipTestFiles = true
25
25
  # The threshold is enforced only while the `text` reporter is on.
26
26
  coverageReporter = ["text", "lcov"]
27
- coverageThreshold = { lines = 0.9, functions = 0.9 }
27
+ coverageThreshold = { lines = 0, functions = 0 }
28
28
 
29
29
  [fmt]
30
30
  useTabs = true
@@ -4,5 +4,5 @@
4
4
  "compilerOptions": {
5
5
  "rootDir": "."
6
6
  },
7
- "exclude": ["**/.build", "**/coverage", "**/dist", "**/target"]
7
+ "exclude": ["**/.build", "**/build", "**/coverage", "**/dist", "**/target"]
8
8
  }
package/oxlintrc.json CHANGED
@@ -339,6 +339,12 @@
339
339
  "import/no-default-export": "off"
340
340
  }
341
341
  },
342
+ {
343
+ "files": ["**/src/theme/**"],
344
+ "rules": {
345
+ "import/no-default-export": "off"
346
+ }
347
+ },
342
348
  {
343
349
  "files": ["**/*.d.{ts,mts,cts}"],
344
350
  "rules": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typescript-bedrock",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
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",
@@ -28,7 +28,7 @@
28
28
  "eslint-plugin-jsdoc": "65.4.5",
29
29
  "markdownlint-cli2": "0.24.0",
30
30
  "markdownlint-rule-title-case-style": "0.4.3",
31
- "oxlint": "1.91.0",
31
+ "oxlint": "1.93.0",
32
32
  "oxlint-tsgolint": "7.0.2006",
33
33
  "typescript": "7.0.2"
34
34
  },
@@ -1,6 +1,7 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { basename, join } from "node:path";
3
3
 
4
+ import { mergeBunfig } from "./bunfig.ts";
4
5
  import { asRecord, exists, type Json, readJsonc, toJson } from "./json.ts";
5
6
  import { migrateManifest } from "./manifest.ts";
6
7
  import { sharedSteps } from "./shared.ts";
@@ -175,6 +176,21 @@ const planManifest = async (
175
176
  );
176
177
  };
177
178
 
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
+
178
194
  const planTsconfig = async (
179
195
  packageRoot: string,
180
196
  target: string,
@@ -218,6 +234,7 @@ export const planAdoption = async (
218
234
  const filesRoot = join(packageRoot, "files");
219
235
  const migrated = await planManifest(packageRoot, target, options);
220
236
  const ts = await planTsconfig(packageRoot, target);
237
+ const bunfig = await planBunfig(filesRoot, target);
221
238
  const ci = Bun.file(join(target, OLD_CI));
222
239
  const oldCi = (await ci.exists()) && OLD_CI_COMMAND.test(await ci.text());
223
240
  const replaced = [
@@ -227,7 +244,7 @@ export const planAdoption = async (
227
244
  const steps: Step[] = [
228
245
  ...(await jsonWrite(target, "package.json", migrated.manifest)),
229
246
  ...(await jsonWrite(target, "tsconfig.json", ts.tsconfig)),
230
- ...(await copies(filesRoot, target, ["bunfig.toml"], true)),
247
+ ...bunfig.steps,
231
248
  ...(await copies(join(filesRoot, "base"), target, BASE_FILES, true)),
232
249
  ...(await copies(filesRoot, target, TOOL_FILES, true)),
233
250
  ...(await copies(filesRoot, target, STARTER_FILES, false)),
@@ -238,6 +255,6 @@ export const planAdoption = async (
238
255
  return {
239
256
  steps: await pending(target, steps),
240
257
  lockfiles: await existing(target, LOCKFILES),
241
- notes: [...migrated.notes, ...ts.notes],
258
+ notes: [...migrated.notes, ...ts.notes, ...bunfig.notes],
242
259
  };
243
260
  };
@@ -17,6 +17,7 @@ const ORDER = [
17
17
  "rust-bedrock",
18
18
  "swift-bedrock",
19
19
  "swift-bedrock-cxx",
20
+ "jvm-bedrock",
20
21
  ];
21
22
 
22
23
  /** Where the first line starting with `marker` begins in `text`, or -1. */
@@ -0,0 +1,202 @@
1
+ import { isArray, isRecord } from "./json.ts";
2
+ import {
3
+ COMMENT,
4
+ type Entry,
5
+ type Path,
6
+ scan,
7
+ type Section,
8
+ } from "./toml-scan.ts";
9
+
10
+ interface Merge {
11
+ readonly lines: string[];
12
+ readonly own: readonly string[];
13
+ readonly base: unknown;
14
+ readonly repo: unknown;
15
+ readonly after: Map<number, string[]>;
16
+ readonly appended: string[];
17
+ readonly notes: string[];
18
+ }
19
+
20
+ const get = (value: unknown, path: Path): unknown => {
21
+ let node = value;
22
+ for (const part of path) {
23
+ if (typeof part === "number") {
24
+ node = isArray(node) ? node[part] : undefined;
25
+ } else {
26
+ node = isRecord(node) ? node[part] : undefined;
27
+ }
28
+ }
29
+ return node;
30
+ };
31
+
32
+ const asList = (value: unknown): readonly unknown[] =>
33
+ isArray(value) ? value : [];
34
+
35
+ const has = (list: readonly unknown[], value: unknown): boolean =>
36
+ list.some((item) => Bun.deepEquals(item, value));
37
+
38
+ const toml = (value: unknown): string => JSON.stringify(value);
39
+
40
+ const valuesOn = (line: string): readonly unknown[] => {
41
+ try {
42
+ // The newline keeps a trailing comment from swallowing the `]`.
43
+ return asList(get(Bun.TOML.parse(`v = [${line}\n]`), ["v"]));
44
+ } catch {
45
+ return [];
46
+ }
47
+ };
48
+
49
+ const insert = (merge: Merge, line: number, text: readonly string[]): void => {
50
+ if (text.length > 0) {
51
+ merge.after.set(line, [...(merge.after.get(line) ?? []), ...text]);
52
+ }
53
+ };
54
+
55
+ const raw = (merge: Merge, from: number, end: number): string =>
56
+ merge.own.slice(from, end + 1).join("\n");
57
+
58
+ /** The repo's lines that hold an entry the template array lacks, with their comments. */
59
+ const carriedLines = (
60
+ merge: Merge,
61
+ entry: Entry,
62
+ mine: readonly unknown[],
63
+ ): { lines: string[]; values: unknown[] } => {
64
+ const lines: string[] = [];
65
+ const values: unknown[] = [];
66
+ let comments: string[] = [];
67
+ for (const line of merge.own.slice(entry.start + 1, entry.end)) {
68
+ if (COMMENT.test(line)) {
69
+ comments.push(line);
70
+ continue;
71
+ }
72
+ const fresh = valuesOn(line).filter((value) => !has(mine, value));
73
+ if (fresh.length > 0) {
74
+ lines.push(...comments, line);
75
+ values.push(...fresh);
76
+ }
77
+ comments = [];
78
+ }
79
+ return { lines, values };
80
+ };
81
+
82
+ const mergeArray = (
83
+ merge: Merge,
84
+ entry: Entry,
85
+ at: Entry,
86
+ mine: readonly unknown[],
87
+ ): void => {
88
+ const theirs = asList(get(merge.repo, entry.path));
89
+ const extra = theirs.filter((value) => !has(mine, value));
90
+ if (extra.length === 0) {
91
+ return;
92
+ }
93
+ if (at.start === at.end) {
94
+ const values = [...mine, ...extra].map((value) => toml(value));
95
+ merge.lines[at.start] = `${at.key} = [${values.join(", ")}]`;
96
+ return;
97
+ }
98
+ const indent = /^\s*/u.exec(merge.lines[at.end - 1] ?? "")?.[0] ?? "";
99
+ const carried = carriedLines(merge, entry, mine);
100
+ const rest = extra.filter((value) => !has(carried.values, value));
101
+ insert(merge, at.end - 1, [
102
+ ...carried.lines,
103
+ ...rest.map((value) => `${indent}${toml(value)},`),
104
+ ]);
105
+ };
106
+
107
+ const mergeTable = (merge: Merge, section: Section, table: Section): void => {
108
+ const keys = new Map(
109
+ table.entries.map((entry) => [JSON.stringify(entry.path), entry]),
110
+ );
111
+ const added: string[] = [];
112
+ for (const entry of section.entries) {
113
+ const mine = get(merge.base, entry.path);
114
+ const theirs = get(merge.repo, entry.path);
115
+ const at = keys.get(JSON.stringify(entry.path));
116
+ if (mine === undefined) {
117
+ added.push(raw(merge, entry.from, entry.end));
118
+ } else if (at !== undefined && isArray(mine) && isArray(theirs)) {
119
+ mergeArray(merge, entry, at, mine);
120
+ } else if (!Bun.deepEquals(mine, theirs)) {
121
+ const name = entry.path.join(".");
122
+ merge.notes.push(
123
+ `bunfig.toml: kept the template's \`${name} = ${toml(mine)}\`; the repo had \`${name} = ${toml(theirs)}\`.`,
124
+ );
125
+ }
126
+ }
127
+ insert(merge, table.end, added);
128
+ };
129
+
130
+ const mergeSection = (
131
+ merge: Merge,
132
+ section: Section,
133
+ tables: Map<string, Section>,
134
+ ): void => {
135
+ const table = tables.get(JSON.stringify(section.path));
136
+ const isNew = section.array
137
+ ? !has(
138
+ asList(get(merge.base, section.path.slice(0, -1))),
139
+ get(merge.repo, section.path),
140
+ )
141
+ : table === undefined;
142
+ if (isNew) {
143
+ merge.appended.push(raw(merge, section.from, section.end));
144
+ } else if (!section.array && table !== undefined) {
145
+ mergeTable(merge, section, table);
146
+ }
147
+ };
148
+
149
+ const render = (merge: Merge): string => {
150
+ const out = [...(merge.after.get(-1) ?? [])];
151
+ for (const [index, line] of merge.lines.entries()) {
152
+ out.push(line, ...(merge.after.get(index) ?? []));
153
+ }
154
+ let text = out.join("\n").replace(/\n*$/u, "\n");
155
+ for (const block of merge.appended) {
156
+ text += `\n${block}\n`;
157
+ }
158
+ return text;
159
+ };
160
+
161
+ /**
162
+ * Writes the template's bunfig.toml, then adds what the repo's copy adds to it:
163
+ * each key a table lacks, each entry a template array lacks, and each table or
164
+ * `[[…]]` element the template lacks, with the comments right above them.
165
+ * Where both set a value, the template's wins, since bunfig.toml cannot
166
+ * extend a shared file; each such value gets a note.
167
+ */
168
+ export const mergeBunfig = (
169
+ template: string,
170
+ current?: string,
171
+ ): { content: string; notes: string[] } => {
172
+ if (current === undefined) {
173
+ return { content: template, notes: [] };
174
+ }
175
+ let repo: unknown;
176
+ try {
177
+ repo = Bun.TOML.parse(current);
178
+ } catch {
179
+ return {
180
+ content: template,
181
+ notes: [
182
+ "bunfig.toml: the repo's copy is not valid TOML, so adopt wrote the template. Move its settings over from bunfig.toml.bak.",
183
+ ],
184
+ };
185
+ }
186
+ const merge: Merge = {
187
+ lines: template.split("\n"),
188
+ own: current.split("\n"),
189
+ base: Bun.TOML.parse(template),
190
+ repo,
191
+ after: new Map(),
192
+ appended: [],
193
+ notes: [],
194
+ };
195
+ const tables = new Map(
196
+ scan(merge.lines).map((section) => [JSON.stringify(section.path), section]),
197
+ );
198
+ for (const section of scan(merge.own)) {
199
+ mergeSection(merge, section, tables);
200
+ }
201
+ return { content: render(merge), notes: merge.notes };
202
+ };
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
 
3
+ import { listFiles } from "../files.ts";
3
4
  import type { Step } from "./steps.ts";
4
5
 
5
6
  const TEST_FILES = new Bun.Glob(
@@ -39,18 +40,15 @@ export const migrateTestFile = (text: string): string => {
39
40
  : text;
40
41
  };
41
42
 
42
- /** Rewrites each test file outside node_modules that is not on bun:test yet. */
43
+ /** Rewrites each test file outside node_modules and git's ignored files that is not on bun:test yet. */
43
44
  export const testSteps = async (target: string): Promise<Step[]> => {
44
- const steps: Step[] = [];
45
- for await (const path of TEST_FILES.scan({ cwd: target, dot: false })) {
46
- if (path.split("/").includes("node_modules")) {
47
- continue;
48
- }
49
- const text = await Bun.file(join(target, path)).text();
45
+ const paths = await listFiles(target, TEST_FILES);
46
+ const texts = await Promise.all(
47
+ paths.map((path) => Bun.file(join(target, path)).text()),
48
+ );
49
+ return paths.flatMap((path, index): Step[] => {
50
+ const text = texts[index] ?? "";
50
51
  const content = migrateTestFile(text);
51
- if (content !== text) {
52
- steps.push({ kind: "write", path, content });
53
- }
54
- }
55
- return steps;
52
+ return content === text ? [] : [{ kind: "write", path, content }];
53
+ });
56
54
  };
@@ -0,0 +1,117 @@
1
+ export type Path = readonly (string | number)[];
2
+
3
+ export interface Entry {
4
+ readonly path: Path;
5
+ readonly key: string;
6
+ /** The first line of the comments right above the key. */
7
+ readonly from: number;
8
+ readonly start: number;
9
+ readonly end: number;
10
+ }
11
+
12
+ export interface Section {
13
+ readonly path: Path;
14
+ readonly array: boolean;
15
+ readonly from: number;
16
+ readonly entries: Entry[];
17
+ end: number;
18
+ }
19
+
20
+ const HEADER = /^\s*(\[\[?)\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/u;
21
+ const KEY = /^\s*([^\s#=[][^=]*?)\s*=/u;
22
+ export const COMMENT = /^\s*#/u;
23
+
24
+ const keyPath = (text: string): string[] =>
25
+ text.split(".").map((part) => part.trim().replace(/^(["'])(.*)\1$/u, "$2"));
26
+
27
+ /** How many arrays the line opens minus how many it closes, outside strings and comments. */
28
+ const brackets = (line: string): number => {
29
+ let depth = 0;
30
+ let quote = "";
31
+ for (const char of line) {
32
+ if (quote !== "") {
33
+ quote = char === quote ? "" : quote;
34
+ } else if (char === '"' || char === "'") {
35
+ quote = char;
36
+ } else if (char === "#") {
37
+ break;
38
+ } else {
39
+ depth += Number(char === "[") - Number(char === "]");
40
+ }
41
+ }
42
+ return depth;
43
+ };
44
+
45
+ /** The last line of the value that starts on `start`. */
46
+ const valueEnd = (
47
+ lines: readonly string[],
48
+ start: number,
49
+ key: string,
50
+ ): number => {
51
+ let end = start;
52
+ let depth = brackets((lines[start] ?? "").slice(key.length));
53
+ while (depth > 0 && end + 1 < lines.length) {
54
+ end += 1;
55
+ depth += brackets(lines[end] ?? "");
56
+ }
57
+ return end;
58
+ };
59
+
60
+ const startSection = (
61
+ header: RegExpExecArray,
62
+ from: number,
63
+ start: number,
64
+ counts: Map<string, number>,
65
+ ): Section => {
66
+ const name = header[2] ?? "";
67
+ const array = header[1] === "[[";
68
+ const count = counts.get(name) ?? 0;
69
+ counts.set(name, count + 1);
70
+ return {
71
+ path: array ? [...keyPath(name), count] : keyPath(name),
72
+ array,
73
+ from,
74
+ entries: [],
75
+ end: start,
76
+ };
77
+ };
78
+
79
+ /** Splits a TOML file into its root and its tables, each with the line range of every key. */
80
+ export const scan = (lines: readonly string[]): Section[] => {
81
+ let section: Section = {
82
+ path: [],
83
+ array: false,
84
+ from: 0,
85
+ entries: [],
86
+ end: -1,
87
+ };
88
+ const sections = [section];
89
+ const counts = new Map<string, number>();
90
+ let from = -1;
91
+ for (let index = 0; index < lines.length; index += 1) {
92
+ const line = lines[index] ?? "";
93
+ if (COMMENT.test(line)) {
94
+ from = from < 0 ? index : from;
95
+ continue;
96
+ }
97
+ const header = HEADER.exec(line);
98
+ const key = KEY.exec(line);
99
+ if (header !== null) {
100
+ section = startSection(header, from < 0 ? index : from, index, counts);
101
+ sections.push(section);
102
+ } else if (key !== null) {
103
+ const end = valueEnd(lines, index, key[0]);
104
+ section.entries.push({
105
+ path: [...section.path, ...keyPath(key[1] ?? "")],
106
+ key: key[1] ?? "",
107
+ from: from < 0 ? index : from,
108
+ start: index,
109
+ end,
110
+ });
111
+ section.end = end;
112
+ index = end;
113
+ }
114
+ from = -1;
115
+ }
116
+ return sections;
117
+ };
@@ -23,8 +23,17 @@ const REMOVED_OPTIONS = new Set([
23
23
  /** Project facts the base config cannot know. */
24
24
  const PROJECT_OPTIONS = new Set(["jsx", "lib", "types"]);
25
25
  const BASE_FILE = "typescript-bedrock/tsconfig.base.json";
26
- /** Build output a repo can hold: Bun's, Rust's (`cargo doc` writes JavaScript), and SwiftPM's. */
27
- const GENERATED_DIRS = ["**/.build", "**/coverage", "**/dist", "**/target"];
26
+ /**
27
+ * Build output a repo can hold: Bun's, Rust's (`cargo doc` writes JavaScript), SwiftPM's, and
28
+ * Gradle's (its test reports are JavaScript).
29
+ */
30
+ const GENERATED_DIRS = [
31
+ "**/.build",
32
+ "**/build",
33
+ "**/coverage",
34
+ "**/dist",
35
+ "**/target",
36
+ ];
28
37
  const EMIT_OPTIONS = [
29
38
  "composite",
30
39
  "declaration",
package/src/files.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { join } from "node:path";
2
+
3
+ import { $, type Glob } from "bun";
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[]> => {
7
+ const git = await $`git ls-files --cached --others --exclude-standard`
8
+ .cwd(cwd)
9
+ .nothrow()
10
+ .quiet();
11
+ const listed = (
12
+ git.exitCode === 0
13
+ ? git.stdout.toString().split("\n")
14
+ : await Array.fromAsync(glob.scan({ cwd }))
15
+ ).filter(
16
+ (path) => glob.match(path) && !path.split("/").includes("node_modules"),
17
+ );
18
+ // The index still lists a file deleted since the last commit.
19
+ const present = await Promise.all(
20
+ listed.map((path) => Bun.file(join(cwd, path)).exists()),
21
+ );
22
+ return listed.filter((_, index) => present[index] === true);
23
+ };
package/src/guard.ts CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { $, Glob } from "bun";
5
5
 
6
6
  import { planAdoption, PROJECT_FILES } from "./adopt/adopt.ts";
7
+ import { listFiles } from "./files.ts";
7
8
 
8
9
  // Written as alternations so this file does not match itself.
9
10
  const SUPPRESSION =
@@ -40,25 +41,6 @@ const FOREIGN_FILES = [
40
41
  "yarn.lock",
41
42
  ];
42
43
 
43
- const listFiles = async (cwd: string, glob: Glob): Promise<string[]> => {
44
- const git = await $`git ls-files --cached --others --exclude-standard`
45
- .cwd(cwd)
46
- .nothrow()
47
- .quiet();
48
- const listed = (
49
- git.exitCode === 0
50
- ? git.stdout.toString().split("\n")
51
- : await Array.fromAsync(glob.scan({ cwd }))
52
- ).filter(
53
- (path) => glob.match(path) && !path.split("/").includes("node_modules"),
54
- );
55
- // The index still lists a file deleted since the last commit.
56
- const present = await Promise.all(
57
- listed.map((path) => Bun.file(join(cwd, path)).exists()),
58
- );
59
- return listed.filter((_, index) => present[index] === true);
60
- };
61
-
62
44
  export const findSuppressions = async (
63
45
  cwd: string,
64
46
  paths: readonly string[],