typescript-bedrock 0.1.2 → 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/files/bunfig.toml.template +1 -1
- package/package.json +1 -1
- package/src/adopt/adopt.ts +7 -44
- package/src/adopt/bunfig.ts +202 -0
- package/src/adopt/copies.ts +92 -0
- package/src/adopt/ignore-lists.ts +41 -0
- package/src/adopt/tests.ts +10 -2
- package/src/adopt/toml-scan.ts +117 -0
- package/src/files.ts +15 -3
- package/src/guard.ts +24 -14
- package/src/ignores.ts +55 -0
|
@@ -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
|
|
27
|
+
coverageThreshold = { lines = 0, functions = 0 }
|
|
28
28
|
|
|
29
29
|
[fmt]
|
|
30
30
|
useTabs = true
|
package/package.json
CHANGED
package/src/adopt/adopt.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { copies, existing, planBunfig } from "./copies.ts";
|
|
5
|
+
import { asRecord, type Json, readJsonc, toJson } from "./json.ts";
|
|
5
6
|
import { migrateManifest } from "./manifest.ts";
|
|
6
7
|
import { sharedSteps } from "./shared.ts";
|
|
7
8
|
import { pending, type Step } from "./steps.ts";
|
|
@@ -99,7 +100,8 @@ export const BASE_FILES = [
|
|
|
99
100
|
".markdownlint.jsonc",
|
|
100
101
|
"justfile",
|
|
101
102
|
];
|
|
102
|
-
/** 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. */
|
|
103
105
|
const TOOL_FILES = [
|
|
104
106
|
".bun-version",
|
|
105
107
|
".github/workflows/typescript.yml",
|
|
@@ -113,46 +115,6 @@ const STARTER_FILES = [".vscode/extensions.json", ".vscode/settings.json"];
|
|
|
113
115
|
const OLD_CI = ".github/workflows/ci.yml";
|
|
114
116
|
const OLD_CI_COMMAND = /\b(?:npm|npx|pnpm|yarn)\b|bun run verify/u;
|
|
115
117
|
|
|
116
|
-
const existing = async (
|
|
117
|
-
root: string,
|
|
118
|
-
paths: readonly string[],
|
|
119
|
-
): Promise<string[]> => {
|
|
120
|
-
const found = await Promise.all(
|
|
121
|
-
paths.map((path) => exists(join(root, path))),
|
|
122
|
-
);
|
|
123
|
-
return paths.filter((_, index) => found[index] === true);
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
/** npm packs drop `bunfig.toml`, and linters read nested configs, so these keep a suffix. */
|
|
127
|
-
const STORED_WITH_SUFFIX = new Set([
|
|
128
|
-
".markdownlint-cli2.jsonc",
|
|
129
|
-
".oxlintrc.json",
|
|
130
|
-
"bunfig.toml",
|
|
131
|
-
]);
|
|
132
|
-
const storedPath = (path: string): string =>
|
|
133
|
-
STORED_WITH_SUFFIX.has(path) ? `${path}.template` : path;
|
|
134
|
-
|
|
135
|
-
const copyFile = async (dir: string, path: string): Promise<Step> => ({
|
|
136
|
-
kind: "write",
|
|
137
|
-
path,
|
|
138
|
-
content: await Bun.file(join(dir, storedPath(path))).text(),
|
|
139
|
-
executable: path.startsWith(".githooks/"),
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
const copies = async (
|
|
143
|
-
dir: string,
|
|
144
|
-
target: string,
|
|
145
|
-
paths: readonly string[],
|
|
146
|
-
overwrite: boolean,
|
|
147
|
-
): Promise<Step[]> => {
|
|
148
|
-
const present = new Set(overwrite ? [] : await existing(target, paths));
|
|
149
|
-
return Promise.all(
|
|
150
|
-
paths
|
|
151
|
-
.filter((path) => !present.has(path))
|
|
152
|
-
.map((path) => copyFile(dir, path)),
|
|
153
|
-
);
|
|
154
|
-
};
|
|
155
|
-
|
|
156
118
|
const planManifest = async (
|
|
157
119
|
packageRoot: string,
|
|
158
120
|
target: string,
|
|
@@ -218,6 +180,7 @@ export const planAdoption = async (
|
|
|
218
180
|
const filesRoot = join(packageRoot, "files");
|
|
219
181
|
const migrated = await planManifest(packageRoot, target, options);
|
|
220
182
|
const ts = await planTsconfig(packageRoot, target);
|
|
183
|
+
const bunfig = await planBunfig(filesRoot, target);
|
|
221
184
|
const ci = Bun.file(join(target, OLD_CI));
|
|
222
185
|
const oldCi = (await ci.exists()) && OLD_CI_COMMAND.test(await ci.text());
|
|
223
186
|
const replaced = [
|
|
@@ -227,7 +190,7 @@ export const planAdoption = async (
|
|
|
227
190
|
const steps: Step[] = [
|
|
228
191
|
...(await jsonWrite(target, "package.json", migrated.manifest)),
|
|
229
192
|
...(await jsonWrite(target, "tsconfig.json", ts.tsconfig)),
|
|
230
|
-
...
|
|
193
|
+
...bunfig.steps,
|
|
231
194
|
...(await copies(join(filesRoot, "base"), target, BASE_FILES, true)),
|
|
232
195
|
...(await copies(filesRoot, target, TOOL_FILES, true)),
|
|
233
196
|
...(await copies(filesRoot, target, STARTER_FILES, false)),
|
|
@@ -238,6 +201,6 @@ export const planAdoption = async (
|
|
|
238
201
|
return {
|
|
239
202
|
steps: await pending(target, steps),
|
|
240
203
|
lockfiles: await existing(target, LOCKFILES),
|
|
241
|
-
notes: [...migrated.notes, ...ts.notes],
|
|
204
|
+
notes: [...migrated.notes, ...ts.notes, ...bunfig.notes],
|
|
242
205
|
};
|
|
243
206
|
};
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
};
|
package/src/adopt/tests.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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(
|
|
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
|
);
|
|
@@ -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
|
+
};
|
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
|
-
|
|
6
|
-
|
|
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) =>
|
|
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 (
|
|
185
|
-
|
|
186
|
-
|
|
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 (
|
|
219
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
+
};
|