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.
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typescript-bedrock",
3
- "version": "0.1.2",
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,7 +1,8 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { basename, join } from "node:path";
3
3
 
4
- 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";
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
- ...(await copies(filesRoot, target, ["bunfig.toml"], true)),
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
+ };
@@ -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
  );
@@ -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
- /** 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
+ };