@homeflare/config 0.1.1 → 0.2.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/README.md CHANGED
@@ -1,12 +1,22 @@
1
1
  # @homeflare/config
2
2
 
3
- Shared TypeScript, oxlint and oxfmt configuration. One place to change a rule, rather
4
- than one copy per repo that drifts.
3
+ The HomeFlare toolchain, as configuration. One place to change a rule, rather than one
4
+ copy per repo that drifts.
5
5
 
6
6
  ```sh
7
7
  bun add -D @homeflare/config
8
8
  ```
9
9
 
10
+ ## What it gives you
11
+
12
+ | file | how to use it |
13
+ | -------------------- | --------------------------------------- |
14
+ | `tsconfig.base.json` | `extends` — apps and Workers |
15
+ | `tsconfig.lib.json` | `extends` — packages that publish types |
16
+ | `oxlintrc.json` | `extends` — 36 rules, 6 plugins |
17
+ | `oxfmtrc.json` | copy to `.oxfmtrc.json` |
18
+ | `bunfig.toml` | copy to `bunfig.toml` |
19
+
10
20
  ## tsconfig
11
21
 
12
22
  ```jsonc
@@ -17,11 +27,10 @@ bun add -D @homeflare/config
17
27
  { "extends": "@homeflare/config/tsconfig.lib.json" }
18
28
  ```
19
29
 
20
- `tsconfig.lib.json` adds `isolatedDeclarations` and `declaration` on top of the base.
21
- ⛔ Both are needed together — `isolatedDeclarations` alone is TS5069, even under
22
- `--noEmit`.
30
+ `tsconfig.lib.json` adds `isolatedDeclarations` and `declaration`. ⛔ Both are needed
31
+ together — `isolatedDeclarations` alone is TS5069, even under `--noEmit`.
23
32
 
24
- The base turns on `strict`, plus the three flags that catch the most runtime bugs:
33
+ The base turns on `strict` plus the flags that catch the most runtime bugs:
25
34
  `noUncheckedIndexedAccess` (`arr[0]` is `T | undefined`), `exactOptionalPropertyTypes`,
26
35
  and `noFallthroughCasesInSwitch`.
27
36
 
@@ -34,14 +43,41 @@ and `noFallthroughCasesInSwitch`.
34
43
  ⚠️ oxlint's `extends` takes **file paths**, not package names — there is no
35
44
  `eslint-config-*` style resolution, so the path into `node_modules` is written out.
36
45
 
37
- ## oxfmt
46
+ 36 rules across `typescript`, `unicorn`, `oxc`, `import`, `react` and `jsx-a11y`. The ones
47
+ worth knowing:
48
+
49
+ - `no-console: error` — ⚠️ except where `console` _is_ the transport, as in a Worker
50
+ writing JSON for Workers Logs. Override it per file, not globally.
51
+ - `typescript/no-non-null-assertion` — `!` hides exactly what `noUncheckedIndexedAccess`
52
+ is trying to surface.
53
+ - `react/exhaustive-deps`, `jsx-a11y/click-events-have-key-events` — only bite in React
54
+ code; a Worker never sees them.
55
+
56
+ ## oxfmt and bunfig
38
57
 
39
58
  ```sh
40
59
  cp node_modules/@homeflare/config/oxfmtrc.json .oxfmtrc.json
60
+ cp node_modules/@homeflare/config/bunfig.toml bunfig.toml
61
+ ```
62
+
63
+ ⚠️ `.oxfmtrc.json` needs the **leading dot**. Without it oxfmt silently uses its defaults,
64
+ and the symptom is a formatter that rewrites your quotes.
65
+
66
+ ⛔ Neither format supports `extends`, so these two are copies. That means they can drift —
67
+ which is what `@homeflare/config/check` exists to catch.
68
+
69
+ ## Keeping a project honest
70
+
71
+ ```ts
72
+ import { checkProject } from '@homeflare/config/check';
73
+
74
+ const problems = await checkProject(process.cwd());
75
+ if (problems.length > 0) throw new Error(problems.join('\n'));
41
76
  ```
42
77
 
43
- ⚠️ The file must be named `.oxfmtrc.json` — **with the leading dot**. Without it oxfmt
44
- silently uses its defaults, and the symptom is a formatter that rewrites your quotes.
78
+ ★ Run it from a test. It asserts a project still extends the shared tsconfig and oxlint
79
+ presets, and that its copied `.oxfmtrc.json` and `bunfig.toml` still match — so a project
80
+ that quietly diverges fails its own suite rather than drifting for six months.
45
81
 
46
82
  ## License
47
83
 
package/bunfig.toml ADDED
@@ -0,0 +1,22 @@
1
+ # The HomeFlare bun defaults.
2
+ #
3
+ # ★ ADOPTION IS A COPY, NOT AN EXTEND. bunfig.toml has no `extends`, so this is the one
4
+ # file in the preset that a consumer copies rather than references:
5
+ # cp node_modules/@homeflare/config/bunfig.toml bunfig.toml
6
+ # ⚠️ That means it CAN drift. `homeflareConfig()` in @homeflare/config/check asserts a
7
+ # project's copy still matches, so drift fails a test instead of going unnoticed.
8
+
9
+ [install]
10
+ # ⛔ EXACT VERSIONS, ALWAYS. A caret in a manifest means two machines resolve different
11
+ # trees on different days, which is the drift a catalog exists to prevent.
12
+ exact = true
13
+
14
+ [install.cache]
15
+ # ★ Bun's global cache, shared across every project on the machine.
16
+ disable = false
17
+
18
+ [test]
19
+ # ⚠️ Coverage is REPORTED by default and gated per project. A repo-wide threshold here
20
+ # would either be too low to matter or fail every new package on day one.
21
+ coverage = false
22
+ coverageReporter = ["text"]
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Assert that a project still stands on the shared config.
3
+ *
4
+ * ★ WHY THIS EXISTS. Two of the five presets — `.oxfmtrc.json` and `bunfig.toml` — have no
5
+ * `extends` mechanism, so adopting them means COPYING them. A copy drifts silently: the
6
+ * project keeps building, the rules quietly diverge, and nobody finds out until two
7
+ * repos disagree about how the same code should look.
8
+ *
9
+ * ⛔ IT REPORTS, IT DOES NOT REPAIR. A checker that rewrites a project's config would
10
+ * overwrite a deliberate local exception, and this cannot tell the difference between
11
+ * drift and a decision. It names what differs; a human decides which way to move.
12
+ *
13
+ * ⚠️ Every file it reads is parsed natively — `Bun.file().json()`, `Bun.TOML.parse`. No
14
+ * parsing dependency reaches a consumer.
15
+ * ⛔ NOT JSONC. Bun's `.json()` rejects comments (measured 2026-09-15), so a project
16
+ * whose tsconfig carries them is reported as unreadable rather than silently skipped.
17
+ */
18
+ /** One thing a project should fix, in the imperative. */
19
+ export type Problem = string;
20
+ /**
21
+ * Check a project directory. Returns an empty array when everything is in step.
22
+ *
23
+ * const problems = await checkProject(process.cwd());
24
+ * expect(problems).toEqual([]);
25
+ */
26
+ export declare function checkProject(projectDir: string): Promise<readonly Problem[]>;
27
+ //# sourceMappingURL=check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/check.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,yDAAyD;AACzD,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAkH7B;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,OAAO,EAAE,CAAC,CASlF"}
package/dist/check.js ADDED
@@ -0,0 +1,90 @@
1
+ // @bun
2
+ // src/check.ts
3
+ function stripComments(text) {
4
+ let out = "";
5
+ let inString = false;
6
+ let escaped = false;
7
+ for (let i = 0;i < text.length; i += 1) {
8
+ const ch = text[i] ?? "";
9
+ const next = text[i + 1] ?? "";
10
+ if (inString) {
11
+ out += ch;
12
+ if (escaped)
13
+ escaped = false;
14
+ else if (ch === "\\")
15
+ escaped = true;
16
+ else if (ch === '"')
17
+ inString = false;
18
+ continue;
19
+ }
20
+ if (ch === '"') {
21
+ inString = true;
22
+ out += ch;
23
+ } else if (ch === "/" && next === "/") {
24
+ while (i < text.length && text[i] !== `
25
+ `)
26
+ i += 1;
27
+ out += `
28
+ `;
29
+ } else if (ch === "/" && next === "*") {
30
+ i += 2;
31
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
32
+ i += 1;
33
+ i += 1;
34
+ } else {
35
+ out += ch;
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ async function readJson(path) {
41
+ const file = Bun.file(path);
42
+ if (!await file.exists())
43
+ return;
44
+ try {
45
+ return JSON.parse(stripComments(await file.text()));
46
+ } catch {
47
+ return;
48
+ }
49
+ }
50
+ function presetDir() {
51
+ return new URL("../", import.meta.url).pathname;
52
+ }
53
+ async function checkExtends(projectDir, file, needle, problems) {
54
+ const config = await readJson(`${projectDir}/${file}`);
55
+ if (config === undefined) {
56
+ problems.push(`${file}: missing or unparseable (JSONC comments are not supported)`);
57
+ return;
58
+ }
59
+ const extended = JSON.stringify(config["extends"] ?? "");
60
+ const workspacePath = needle.replace("@homeflare/config/", "packages/config/");
61
+ if (!extended.includes(needle) && !extended.includes(workspacePath)) {
62
+ problems.push(`${file}: does not extend ${needle} \u2014 it currently extends ${extended}`);
63
+ }
64
+ }
65
+ async function checkCopy(projectDir, file, parse, problems) {
66
+ const mine = Bun.file(`${projectDir}/${file}`);
67
+ if (!await mine.exists()) {
68
+ problems.push(`${file}: missing \u2014 copy it from @homeflare/config`);
69
+ return;
70
+ }
71
+ const preset = parse(await Bun.file(`${presetDir()}${file.replace(/^\./, "")}`).text());
72
+ const theirs = parse(await mine.text());
73
+ if (JSON.stringify(theirs) !== JSON.stringify(preset)) {
74
+ problems.push(`${file}: differs from @homeflare/config \u2014 re-copy it, or say why it differs`);
75
+ }
76
+ }
77
+ async function checkProject(projectDir) {
78
+ const problems = [];
79
+ await checkExtends(projectDir, "tsconfig.json", "@homeflare/config/tsconfig", problems);
80
+ await checkExtends(projectDir, ".oxlintrc.json", "@homeflare/config/oxlintrc", problems);
81
+ await checkCopy(projectDir, ".oxfmtrc.json", (t) => JSON.parse(t), problems);
82
+ await checkCopy(projectDir, "bunfig.toml", (t) => Bun.TOML.parse(t), problems);
83
+ return problems;
84
+ }
85
+ export {
86
+ checkProject
87
+ };
88
+
89
+ //# debugId=7F6869C495728F5464756E2164756E21
90
+ //# sourceMappingURL=check.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/check.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * Assert that a project still stands on the shared config.\n *\n * ★ WHY THIS EXISTS. Two of the five presets — `.oxfmtrc.json` and `bunfig.toml` — have no\n * `extends` mechanism, so adopting them means COPYING them. A copy drifts silently: the\n * project keeps building, the rules quietly diverge, and nobody finds out until two\n * repos disagree about how the same code should look.\n *\n * ⛔ IT REPORTS, IT DOES NOT REPAIR. A checker that rewrites a project's config would\n * overwrite a deliberate local exception, and this cannot tell the difference between\n * drift and a decision. It names what differs; a human decides which way to move.\n *\n * ⚠️ Every file it reads is parsed natively — `Bun.file().json()`, `Bun.TOML.parse`. No\n * parsing dependency reaches a consumer.\n * ⛔ NOT JSONC. Bun's `.json()` rejects comments (measured 2026-09-15), so a project\n * whose tsconfig carries them is reported as unreadable rather than silently skipped.\n */\n\n/** One thing a project should fix, in the imperative. */\nexport type Problem = string;\n\ntype Json = Record<string, unknown>;\n\n/**\n * ⚠️ A tsconfig LEGITIMATELY carries comments — that is where the reasoning for a strict\n * flag lives, and stripping them to satisfy a parser would be the wrong trade. Bun's\n * `.json()` rejects them (measured 2026-09-15), so comments are removed before parsing\n * rather than treated as a fault.\n * 🔴 A NAIVE REGEX CORRUPTS THE FILE IT IS READING. Measured while writing this: a\n * `(^|[^:\"])//.*$` strip mangled `\"./node_modules/oxlint/...\"` — the `//` inside a\n * STRING — turning valid JSON into a parse error reported as \"JSONC not supported\".\n * So this walks the text and tracks whether it is inside a string, which is the\n * smallest correct thing; a real JSONC parser would be a dependency every consumer\n * inherits for one check.\n */\nfunction stripComments(text: string): string {\n let out = '';\n let inString = false;\n let escaped = false;\n\n for (let i = 0; i < text.length; i += 1) {\n const ch = text[i] ?? '';\n const next = text[i + 1] ?? '';\n\n if (inString) {\n out += ch;\n if (escaped) escaped = false;\n else if (ch === '\\\\') escaped = true;\n else if (ch === '\"') inString = false;\n continue;\n }\n\n if (ch === '\"') {\n inString = true;\n out += ch;\n } else if (ch === '/' && next === '/') {\n while (i < text.length && text[i] !== '\\n') i += 1;\n out += '\\n';\n } else if (ch === '/' && next === '*') {\n i += 2;\n while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;\n i += 1;\n } else {\n out += ch;\n }\n }\n\n return out;\n}\n\nasync function readJson(path: string): Promise<Json | undefined> {\n const file = Bun.file(path);\n if (!(await file.exists())) return undefined;\n try {\n return JSON.parse(stripComments(await file.text())) as Json;\n } catch {\n return undefined;\n }\n}\n\n/** Where this package's own presets live, however it was installed. */\nfunction presetDir(): string {\n return new URL('../', import.meta.url).pathname;\n}\n\nasync function checkExtends(\n projectDir: string,\n file: string,\n needle: string,\n problems: Problem[],\n): Promise<void> {\n const config = await readJson(`${projectDir}/${file}`);\n if (config === undefined) {\n problems.push(`${file}: missing or unparseable (JSONC comments are not supported)`);\n return;\n }\n\n const extended = JSON.stringify(config['extends'] ?? '');\n\n // ⚠️ A WORKSPACE PATH COUNTS. Inside this repo the presets are referenced as\n // `./packages/config/...` rather than by package name, and reporting that as drift\n // would make the check fail in the one repo that defines the config.\n const workspacePath = needle.replace('@homeflare/config/', 'packages/config/');\n if (!extended.includes(needle) && !extended.includes(workspacePath)) {\n problems.push(`${file}: does not extend ${needle} — it currently extends ${extended}`);\n }\n}\n\n/**\n * Compare a copied file against the preset, ignoring formatting.\n * ⚠️ Structural comparison, not text: a project that reformats its copy has not drifted,\n * and reporting that would train people to ignore this check.\n */\nasync function checkCopy(\n projectDir: string,\n file: string,\n parse: (text: string) => unknown,\n problems: Problem[],\n): Promise<void> {\n const mine = Bun.file(`${projectDir}/${file}`);\n if (!(await mine.exists())) {\n problems.push(`${file}: missing — copy it from @homeflare/config`);\n return;\n }\n\n const preset = parse(await Bun.file(`${presetDir()}${file.replace(/^\\./, '')}`).text());\n const theirs = parse(await mine.text());\n\n if (JSON.stringify(theirs) !== JSON.stringify(preset)) {\n problems.push(`${file}: differs from @homeflare/config — re-copy it, or say why it differs`);\n }\n}\n\n/**\n * Check a project directory. Returns an empty array when everything is in step.\n *\n * const problems = await checkProject(process.cwd());\n * expect(problems).toEqual([]);\n */\nexport async function checkProject(projectDir: string): Promise<readonly Problem[]> {\n const problems: Problem[] = [];\n\n await checkExtends(projectDir, 'tsconfig.json', '@homeflare/config/tsconfig', problems);\n await checkExtends(projectDir, '.oxlintrc.json', '@homeflare/config/oxlintrc', problems);\n await checkCopy(projectDir, '.oxfmtrc.json', (t) => JSON.parse(t) as unknown, problems);\n await checkCopy(projectDir, 'bunfig.toml', (t) => Bun.TOML.parse(t), problems);\n\n return problems;\n}\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "7F6869C495728F5464756E2164756E21",
9
+ "names": []
10
+ }
package/oxlintrc.json CHANGED
@@ -1,19 +1,75 @@
1
1
  {
2
- "plugins": ["typescript", "unicorn", "oxc", "import"],
2
+ "plugins": ["typescript", "unicorn", "oxc", "import", "react", "jsx-a11y"],
3
3
  "categories": {
4
4
  "correctness": "error",
5
- "suspicious": "warn",
6
- "perf": "warn"
5
+ "suspicious": "off",
6
+ "pedantic": "off",
7
+ "style": "off",
8
+ "restriction": "off",
9
+ "nursery": "off"
7
10
  },
8
11
  "env": {
9
- "builtin": true,
10
- "es2024": true
12
+ "es2024": true,
13
+ "node": true,
14
+ "browser": true,
15
+ "serviceworker": true
11
16
  },
12
17
  "ignorePatterns": ["dist/**", "coverage/**"],
13
18
  "rules": {
19
+ "no-unused-vars": [
20
+ "error",
21
+ {
22
+ "args": "none",
23
+ "caughtErrors": "none",
24
+ "varsIgnorePattern": "^_",
25
+ "ignoreRestSiblings": true
26
+ }
27
+ ],
28
+ "import/default": "off",
29
+ "import/no-unassigned-import": "off",
30
+ "no-underscore-dangle": "off",
31
+ "unicorn/no-empty-file": "off",
32
+ "eqeqeq": [
33
+ "error",
34
+ "always",
35
+ {
36
+ "null": "ignore"
37
+ }
38
+ ],
14
39
  "no-console": "error",
15
- "typescript/consistent-type-imports": "error",
40
+ "no-control-regex": "error",
41
+ "no-fallthrough": "error",
42
+ "no-shadow-restricted-names": "error",
43
+ "no-template-curly-in-string": "error",
16
44
  "typescript/no-explicit-any": "error",
45
+ "default-param-last": "error",
46
+ "no-param-reassign": "error",
47
+ "operator-assignment": "error",
48
+ "prefer-const": "error",
49
+ "prefer-template": "error",
50
+ "typescript/no-non-null-assertion": "error",
51
+ "typescript/no-extraneous-class": "error",
52
+ "typescript/no-this-alias": "error",
53
+ "unicorn/no-array-for-each": "warn",
54
+ "unicorn/prefer-array-flat-map": "error",
55
+ "oxc/no-accumulating-spread": "error",
56
+ "react/no-danger": "error",
57
+ "react/button-has-type": "error",
58
+ "react/exhaustive-deps": "error",
59
+ "react/react-in-jsx-scope": "off",
60
+ "jsx-a11y/click-events-have-key-events": "error",
61
+ "jsx-a11y/prefer-tag-over-role": "off",
62
+ "react/set-state-in-effect": "off",
63
+ "typescript/dot-notation": "off",
64
+ "no-shadow": "off",
65
+ "unicorn/consistent-function-scoping": "off",
66
+ "typescript/consistent-type-imports": "error",
67
+ "sort-imports": [
68
+ "error",
69
+ {
70
+ "ignoreDeclarationSort": true
71
+ }
72
+ ],
17
73
  "import/no-default-export": "error"
18
74
  },
19
75
  "overrides": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/config",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Shared tsconfig, oxlint and oxfmt configuration for HomeFlare projects.",
5
5
  "license": "MIT",
6
6
  "author": "Timothy Schneider",
@@ -10,10 +10,13 @@
10
10
  "directory": "packages/config"
11
11
  },
12
12
  "files": [
13
+ "dist",
14
+ "src",
13
15
  "tsconfig.base.json",
14
16
  "tsconfig.lib.json",
15
17
  "oxlintrc.json",
16
18
  "oxfmtrc.json",
19
+ "bunfig.toml",
17
20
  "README.md",
18
21
  "LICENSE"
19
22
  ],
@@ -23,13 +26,14 @@
23
26
  "./tsconfig.lib.json": "./tsconfig.lib.json",
24
27
  "./oxlintrc.json": "./oxlintrc.json",
25
28
  "./oxfmtrc.json": "./oxfmtrc.json",
29
+ "./bunfig.toml": "./bunfig.toml",
30
+ "./check": {
31
+ "types": "./dist/check.d.ts",
32
+ "default": "./dist/check.js"
33
+ },
26
34
  "./package.json": "./package.json"
27
35
  },
28
36
  "publishConfig": {
29
37
  "access": "public"
30
- },
31
- "scripts": {
32
- "build": "echo 'config: nothing to build'",
33
- "clean": "echo 'config: nothing to clean'"
34
38
  }
35
39
  }
package/src/check.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Assert that a project still stands on the shared config.
3
+ *
4
+ * ★ WHY THIS EXISTS. Two of the five presets — `.oxfmtrc.json` and `bunfig.toml` — have no
5
+ * `extends` mechanism, so adopting them means COPYING them. A copy drifts silently: the
6
+ * project keeps building, the rules quietly diverge, and nobody finds out until two
7
+ * repos disagree about how the same code should look.
8
+ *
9
+ * ⛔ IT REPORTS, IT DOES NOT REPAIR. A checker that rewrites a project's config would
10
+ * overwrite a deliberate local exception, and this cannot tell the difference between
11
+ * drift and a decision. It names what differs; a human decides which way to move.
12
+ *
13
+ * ⚠️ Every file it reads is parsed natively — `Bun.file().json()`, `Bun.TOML.parse`. No
14
+ * parsing dependency reaches a consumer.
15
+ * ⛔ NOT JSONC. Bun's `.json()` rejects comments (measured 2026-09-15), so a project
16
+ * whose tsconfig carries them is reported as unreadable rather than silently skipped.
17
+ */
18
+
19
+ /** One thing a project should fix, in the imperative. */
20
+ export type Problem = string;
21
+
22
+ type Json = Record<string, unknown>;
23
+
24
+ /**
25
+ * ⚠️ A tsconfig LEGITIMATELY carries comments — that is where the reasoning for a strict
26
+ * flag lives, and stripping them to satisfy a parser would be the wrong trade. Bun's
27
+ * `.json()` rejects them (measured 2026-09-15), so comments are removed before parsing
28
+ * rather than treated as a fault.
29
+ * 🔴 A NAIVE REGEX CORRUPTS THE FILE IT IS READING. Measured while writing this: a
30
+ * `(^|[^:"])//.*$` strip mangled `"./node_modules/oxlint/..."` — the `//` inside a
31
+ * STRING — turning valid JSON into a parse error reported as "JSONC not supported".
32
+ * So this walks the text and tracks whether it is inside a string, which is the
33
+ * smallest correct thing; a real JSONC parser would be a dependency every consumer
34
+ * inherits for one check.
35
+ */
36
+ function stripComments(text: string): string {
37
+ let out = '';
38
+ let inString = false;
39
+ let escaped = false;
40
+
41
+ for (let i = 0; i < text.length; i += 1) {
42
+ const ch = text[i] ?? '';
43
+ const next = text[i + 1] ?? '';
44
+
45
+ if (inString) {
46
+ out += ch;
47
+ if (escaped) escaped = false;
48
+ else if (ch === '\\') escaped = true;
49
+ else if (ch === '"') inString = false;
50
+ continue;
51
+ }
52
+
53
+ if (ch === '"') {
54
+ inString = true;
55
+ out += ch;
56
+ } else if (ch === '/' && next === '/') {
57
+ while (i < text.length && text[i] !== '\n') i += 1;
58
+ out += '\n';
59
+ } else if (ch === '/' && next === '*') {
60
+ i += 2;
61
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
62
+ i += 1;
63
+ } else {
64
+ out += ch;
65
+ }
66
+ }
67
+
68
+ return out;
69
+ }
70
+
71
+ async function readJson(path: string): Promise<Json | undefined> {
72
+ const file = Bun.file(path);
73
+ if (!(await file.exists())) return undefined;
74
+ try {
75
+ return JSON.parse(stripComments(await file.text())) as Json;
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ /** Where this package's own presets live, however it was installed. */
82
+ function presetDir(): string {
83
+ return new URL('../', import.meta.url).pathname;
84
+ }
85
+
86
+ async function checkExtends(
87
+ projectDir: string,
88
+ file: string,
89
+ needle: string,
90
+ problems: Problem[],
91
+ ): Promise<void> {
92
+ const config = await readJson(`${projectDir}/${file}`);
93
+ if (config === undefined) {
94
+ problems.push(`${file}: missing or unparseable (JSONC comments are not supported)`);
95
+ return;
96
+ }
97
+
98
+ const extended = JSON.stringify(config['extends'] ?? '');
99
+
100
+ // ⚠️ A WORKSPACE PATH COUNTS. Inside this repo the presets are referenced as
101
+ // `./packages/config/...` rather than by package name, and reporting that as drift
102
+ // would make the check fail in the one repo that defines the config.
103
+ const workspacePath = needle.replace('@homeflare/config/', 'packages/config/');
104
+ if (!extended.includes(needle) && !extended.includes(workspacePath)) {
105
+ problems.push(`${file}: does not extend ${needle} — it currently extends ${extended}`);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Compare a copied file against the preset, ignoring formatting.
111
+ * ⚠️ Structural comparison, not text: a project that reformats its copy has not drifted,
112
+ * and reporting that would train people to ignore this check.
113
+ */
114
+ async function checkCopy(
115
+ projectDir: string,
116
+ file: string,
117
+ parse: (text: string) => unknown,
118
+ problems: Problem[],
119
+ ): Promise<void> {
120
+ const mine = Bun.file(`${projectDir}/${file}`);
121
+ if (!(await mine.exists())) {
122
+ problems.push(`${file}: missing — copy it from @homeflare/config`);
123
+ return;
124
+ }
125
+
126
+ const preset = parse(await Bun.file(`${presetDir()}${file.replace(/^\./, '')}`).text());
127
+ const theirs = parse(await mine.text());
128
+
129
+ if (JSON.stringify(theirs) !== JSON.stringify(preset)) {
130
+ problems.push(`${file}: differs from @homeflare/config — re-copy it, or say why it differs`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Check a project directory. Returns an empty array when everything is in step.
136
+ *
137
+ * const problems = await checkProject(process.cwd());
138
+ * expect(problems).toEqual([]);
139
+ */
140
+ export async function checkProject(projectDir: string): Promise<readonly Problem[]> {
141
+ const problems: Problem[] = [];
142
+
143
+ await checkExtends(projectDir, 'tsconfig.json', '@homeflare/config/tsconfig', problems);
144
+ await checkExtends(projectDir, '.oxlintrc.json', '@homeflare/config/oxlintrc', problems);
145
+ await checkCopy(projectDir, '.oxfmtrc.json', (t) => JSON.parse(t) as unknown, problems);
146
+ await checkCopy(projectDir, 'bunfig.toml', (t) => Bun.TOML.parse(t), problems);
147
+
148
+ return problems;
149
+ }