@jterrazz/typescript 9.0.0 → 9.1.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.
@@ -289,13 +289,23 @@ run_checks() {
289
289
 
290
290
  # Gitignore (artefacts): the convention — every artefact under
291
291
  # `.artifacts/<tool>/`, `dist` excepted — read off the project's own
292
- # `.gitignore`. Opt-in by existence: a project with no `.gitignore` names no
293
- # artefact path, so the gate has no question to ask. Root-only, because
294
- # `.artifacts/` sits at the project ROOT. Runs in fix mode too it is the
295
- # one gate whose remedy is a rewrite rather than a deletion.
292
+ # `.gitignore` AND, in check mode, the nearest ancestor `.gitignore` above it
293
+ # (the workspace root's check-gitignore.js walks up to find it). Opt-in by
294
+ # existence: neither file names an artefact path, the gate has no question to
295
+ # ask. A workspace whose `lint` delegates to members needs the ancestor probed
296
+ # here, in bash, because it decides whether to print the pass at all — the
297
+ # node script decides everything past that. Fix mode stays own-file-only: it
298
+ # is the one gate whose remedy is a rewrite, and it never rewrites another
299
+ # project's `.gitignore`.
296
300
  local gitignore_pid=""
297
301
  local gitignore_status=0
302
+ local gitignore_applicable=false
298
303
  if [ -f ".gitignore" ]; then
304
+ gitignore_applicable=true
305
+ elif [ "$FIX_MODE" = false ] && node "$PACKAGE_ROOT/lib/check-gitignore.js" --has-ancestor > /dev/null 2>&1; then
306
+ gitignore_applicable=true
307
+ fi
308
+ if [ "$gitignore_applicable" = true ]; then
299
309
  if [ "$FIX_MODE" = true ]; then
300
310
  node "$PACKAGE_ROOT/lib/check-gitignore.js" --fix > "$tmp_dir/gitignore.log" 2>&1 &
301
311
  else
@@ -1,19 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * The artefact convention, read off a project's `.gitignore`.
4
+ * The artefact convention, read off a project's `.gitignore` — and, in a
5
+ * workspace, off the ancestor `.gitignore` that covers it too.
5
6
  *
6
7
  * Every build, test and lint artefact lives under `.artifacts/<tool>/` at the
7
8
  * project root — one folder per tool that writes. A `.gitignore` line naming an
8
9
  * artefact ANYWHERE else is the old layout, and this gate says so; `.artifacts/`
9
10
  * itself must be ignored, so the convention's own folder never reaches a commit.
10
11
  *
11
- * Usage: node check-gitignore.js [--fix] [root]
12
+ * Two files are read, each judged by the same rules: the package's own
13
+ * `.gitignore`, and the nearest ANCESTOR `.gitignore` above it — the workspace
14
+ * root's, found by walking up to the nearest directory holding a lockfile or a
15
+ * `workspaces` manifest. A workspace whose `lint` delegates to members runs this
16
+ * gate once per member, cwd'd there; without the ancestor a root that ignores
17
+ * `.artifacts/` for everyone would look, from a member with no `.gitignore` of
18
+ * its own, exactly like a project declaring nothing. An ancestor pattern counts
19
+ * only when it is NOT anchored to the ancestor's own directory (no `/` besides a
20
+ * trailing one) — the same rule git applies when deciding whether a pattern
21
+ * reaches into a nested directory.
12
22
  *
13
- * `--fix` rewrites the `.gitignore`: the artefact lines go, `.artifacts/` arrives,
23
+ * Usage: node check-gitignore.js [--fix] [--has-ancestor] [root]
24
+ *
25
+ * `--has-ancestor` answers, silently, whether an ancestor `.gitignore` exists
26
+ * above `root` — the probe a caller uses to decide whether the gate has
27
+ * anything to read when `root` itself carries no `.gitignore`.
28
+ *
29
+ * `--fix` rewrites the package's OWN `.gitignore` (never the ancestor's, which
30
+ * is a different project's file): the artefact lines go, `.artifacts/` arrives,
14
31
  * and everything else — comments, blank lines, order, the project's own paths —
15
32
  * survives untouched. A `!` line is NEVER rewritten: it rescues a tracked file,
16
33
  * and deleting the line it negates could hide a real path. Those are reported.
34
+ * Fix has nothing to do when the package owns no `.gitignore` of its own, even
35
+ * if an ancestor exists — it never creates a file, only repairs one.
17
36
  *
18
37
  * Exit code: 0 when the project holds the convention, 1 otherwise. In `--fix`
19
38
  * mode only what the rewrite cannot repair — a committed artefact — still fails.
@@ -21,7 +40,7 @@
21
40
 
22
41
  import { execFileSync } from 'node:child_process';
23
42
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
24
- import { join } from 'node:path';
43
+ import { dirname, join, relative, resolve } from 'node:path';
25
44
  import { argv, exit, stdout } from 'node:process';
26
45
 
27
46
  /** The convention's own directory: the one path that MUST be ignored. */
@@ -77,24 +96,104 @@ const EXCEPTIONS = new Set([
77
96
  const EXCEPTION_PREFIXES = ['.metro-health-check'];
78
97
 
79
98
  /**
80
- * Directories whose every tracked file is a committed artefact. Narrower than
81
- * the list above on purpose: `bin`, `out`, `build` and `target` are ignorable
82
- * as OUTPUT, but a tracked file under them may be a project's own source —
83
- * a `.gitignore` line declares intent, a tracked path declares nothing.
99
+ * `next.config.*` file names, in resolution order mirrors what Next.js
100
+ * itself tries, closely enough for a textual `output` read.
84
101
  */
85
- const TRACKED_ARTEFACT_DIRECTORIES = new Set([
86
- '.artifacts',
87
- '.cache',
88
- '.next',
89
- '.turbo',
90
- '.vite',
91
- 'coverage',
92
- 'playwright-report',
93
- 'test-results',
94
- ]);
102
+ const NEXT_CONFIG_FILES = [
103
+ 'next.config.js',
104
+ 'next.config.mjs',
105
+ 'next.config.ts',
106
+ 'next.config.mts',
107
+ 'next.config.cjs',
108
+ 'next.config.cts',
109
+ ];
95
110
 
96
- /** The extension no tracked file may carry — a buildinfo is never source. */
97
- const TRACKED_ARTEFACT_EXTENSION = '.tsbuildinfo';
111
+ /**
112
+ * With `output: 'export'`, Next reads `distDir` as the EXPORT destination and
113
+ * keeps its working directory at `.next` regardless — `next/dist/export/utils.js`
114
+ * (`hasCustomExportOutput`) refuses to move it. So `.next` cannot be told to
115
+ * live under `.artifacts/next/` in that one mode, proven by a real consumer
116
+ * (clawssify's site). Without `output: 'export'`, nothing pins it, and `.next`
117
+ * stays an ordinary artefact. Read textually — a project's own config may not
118
+ * even be valid JS in the tool's own runtime, and a regex answers the one
119
+ * question this gate has without loading it.
120
+ */
121
+ function nextConfigDeclaresExport(dir) {
122
+ for (const name of NEXT_CONFIG_FILES) {
123
+ const path = join(dir, name);
124
+ if (!existsSync(path)) {
125
+ continue;
126
+ }
127
+ try {
128
+ if (/output\s*:\s*['"]export['"]/.test(readFileSync(path, 'utf8'))) {
129
+ return true;
130
+ }
131
+ } catch {
132
+ // An unreadable config answers no differently than a missing one.
133
+ }
134
+ }
135
+
136
+ return false;
137
+ }
138
+
139
+ /** Lockfiles whose presence marks a directory as a package manager's root. */
140
+ const WORKSPACE_LOCKFILES = [
141
+ 'bun.lock',
142
+ 'bun.lockb',
143
+ 'npm-shrinkwrap.json',
144
+ 'package-lock.json',
145
+ 'pnpm-lock.yaml',
146
+ 'yarn.lock',
147
+ ];
148
+
149
+ /** A workspace root: a lockfile lives here, or its manifest declares `workspaces`. */
150
+ function isWorkspaceRoot(dir) {
151
+ if (WORKSPACE_LOCKFILES.some((name) => existsSync(join(dir, name)))) {
152
+ return true;
153
+ }
154
+
155
+ const manifest = join(dir, 'package.json');
156
+ if (!existsSync(manifest)) {
157
+ return false;
158
+ }
159
+
160
+ try {
161
+ return JSON.parse(readFileSync(manifest, 'utf8')).workspaces !== undefined;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * The nearest ancestor ABOVE `root` that is a workspace root, or null. Never
169
+ * `root` itself — a project reads its OWN `.gitignore` regardless, so only
170
+ * what sits above it is worth a second file.
171
+ */
172
+ function findWorkspaceRoot(root) {
173
+ let dir = dirname(resolve(root));
174
+ let parent = dirname(dir);
175
+
176
+ while (dir !== parent) {
177
+ if (isWorkspaceRoot(dir)) {
178
+ return dir;
179
+ }
180
+ dir = parent;
181
+ parent = dirname(dir);
182
+ }
183
+
184
+ return isWorkspaceRoot(dir) ? dir : null;
185
+ }
186
+
187
+ /**
188
+ * A pattern anchored to its OWN `.gitignore`'s directory — one that carries a
189
+ * `/` other than a trailing one, or a leading one — the way git itself reads
190
+ * it. An anchored pattern in an ANCESTOR's file never reaches a nested package;
191
+ * only an unanchored one (`.artifacts/`, not `/.artifacts/` or `out/.artifacts/`)
192
+ * matches at any depth below it.
193
+ */
194
+ function isAnchored(pattern) {
195
+ return pattern.replace(/\/+$/, '').includes('/');
196
+ }
98
197
 
99
198
  /**
100
199
  * What a `.gitignore` pattern names, reduced to the one segment that carries
@@ -112,12 +211,15 @@ function subject(pattern) {
112
211
  }
113
212
 
114
213
  /** The `.artifacts/` home of the artefact a pattern names, or null. */
115
- function artefactHome(pattern) {
214
+ function artefactHome(pattern, { nextIsExportDestination }) {
116
215
  const name = subject(pattern);
117
216
 
118
217
  if (name === '' || name === PRODUCT || EXCEPTIONS.has(name)) {
119
218
  return null;
120
219
  }
220
+ if (nextIsExportDestination && name === '.next') {
221
+ return null;
222
+ }
121
223
  if (EXCEPTION_PREFIXES.some((prefix) => name.startsWith(prefix))) {
122
224
  return null;
123
225
  }
@@ -133,8 +235,9 @@ function artefactHome(pattern) {
133
235
  return null;
134
236
  }
135
237
 
136
- /** Every line of the file, classified once — the rewrite reads the same list. */
238
+ /** Every line of a `.gitignore`, classified once — the rewrite reads the same list. */
137
239
  function readLines(gitignorePath) {
240
+ const nextIsExportDestination = nextConfigDeclaresExport(dirname(gitignorePath));
138
241
  const text = readFileSync(gitignorePath, 'utf8').split('\n');
139
242
 
140
243
  // The empty string a trailing newline leaves behind is not a line.
@@ -148,7 +251,10 @@ function readLines(gitignorePath) {
148
251
  const isPattern = pattern !== '' && !pattern.startsWith('#');
149
252
 
150
253
  return {
151
- home: isPattern && !pattern.startsWith('!') ? artefactHome(pattern) : null,
254
+ home:
255
+ isPattern && !pattern.startsWith('!')
256
+ ? artefactHome(pattern, { nextIsExportDestination })
257
+ : null,
152
258
  isNegation: isPattern && pattern.startsWith('!'),
153
259
  pattern,
154
260
  text: line,
@@ -156,6 +262,52 @@ function readLines(gitignorePath) {
156
262
  });
157
263
  }
158
264
 
265
+ /**
266
+ * A `.gitignore` judged on its own: every line classified, plus what it
267
+ * contributes to the combined verdict. `reach` narrows an ancestor's file to
268
+ * the patterns that actually cross into a nested package — every line, for
269
+ * the package's own file, since that file's directory IS the project root.
270
+ */
271
+ function readSource(path, { own, reach = () => true, root }) {
272
+ const lines = readLines(path);
273
+
274
+ return {
275
+ isIgnored: lines.some(
276
+ (line) =>
277
+ !line.isNegation &&
278
+ line.home === null &&
279
+ subject(line.pattern) === ARTIFACTS &&
280
+ reach(line.pattern),
281
+ ),
282
+ label: relative(resolve(root), path).split('\\').join('/'),
283
+ lines,
284
+ misplaced: lines.filter((line) => line.home !== null && reach(line.pattern)),
285
+ negations: lines.filter((line) => line.isNegation),
286
+ own,
287
+ path,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Directories whose every tracked file is a committed artefact. Narrower than
293
+ * the list above on purpose: `bin`, `out`, `build` and `target` are ignorable
294
+ * as OUTPUT, but a tracked file under them may be a project's own source —
295
+ * a `.gitignore` line declares intent, a tracked path declares nothing.
296
+ */
297
+ const TRACKED_ARTEFACT_DIRECTORIES = new Set([
298
+ '.artifacts',
299
+ '.cache',
300
+ '.next',
301
+ '.turbo',
302
+ '.vite',
303
+ 'coverage',
304
+ 'playwright-report',
305
+ 'test-results',
306
+ ]);
307
+
308
+ /** The extension no tracked file may carry — a buildinfo is never source. */
309
+ const TRACKED_ARTEFACT_EXTENSION = '.tsbuildinfo';
310
+
159
311
  /**
160
312
  * The artefacts a project COMMITTED. Outside a git tree the question has no
161
313
  * answer, and silence is the right one — a fixture directory is not a repo.
@@ -204,45 +356,86 @@ function rewrite(lines) {
204
356
  }
205
357
 
206
358
  const isFix = argv.includes('--fix');
359
+ const isProbe = argv.includes('--has-ancestor');
207
360
  const root = argv.slice(2).find((argument) => !argument.startsWith('--')) ?? '.';
208
- const gitignorePath = join(root, '.gitignore');
209
361
 
210
- // A project with no `.gitignore` declares no artefact path and nothing to fix.
211
- // The gate has no question to ask of it, and stays silent.
212
- if (!existsSync(gitignorePath)) {
362
+ const workspaceRoot = findWorkspaceRoot(root);
363
+ const ancestorPath = workspaceRoot ? join(workspaceRoot, '.gitignore') : null;
364
+
365
+ // The probe answers one question — does an ancestor `.gitignore` exist above
366
+ // `root` — for a caller that already knows how to test `root`'s own.
367
+ if (isProbe) {
368
+ exit(ancestorPath && existsSync(ancestorPath) ? 0 : 1);
369
+ }
370
+
371
+ const ownPath = join(root, '.gitignore');
372
+ const sources = [];
373
+ if (existsSync(ownPath)) {
374
+ sources.push(readSource(ownPath, { own: true, root }));
375
+ }
376
+ if (ancestorPath && existsSync(ancestorPath)) {
377
+ sources.push(
378
+ readSource(ancestorPath, { own: false, reach: (pattern) => !isAnchored(pattern), root }),
379
+ );
380
+ }
381
+
382
+ /*
383
+ * Neither the package nor any ancestor above it declares an artefact path —
384
+ * the gate has no question to ask, and stays silent.
385
+ */
386
+ if (sources.length === 0) {
213
387
  exit(0);
214
388
  }
215
389
 
216
- const lines = readLines(gitignorePath);
217
- const misplaced = lines.filter((line) => line.home !== null);
218
- const negations = lines.filter((line) => line.isNegation);
219
- const isIgnored = lines.some(
220
- (line) => !line.isNegation && line.home === null && subject(line.pattern) === ARTIFACTS,
390
+ /*
391
+ * The combined verdict, own file and ancestor alike — an ancestor's line
392
+ * already narrowed to what actually reaches this package (`readSource`'s
393
+ * `reach`). Negations keep the original, file-agnostic wording: rescuing a
394
+ * tracked file is the same judgement call regardless of which file names it.
395
+ */
396
+ const isIgnored = sources.some((source) => source.isIgnored);
397
+ const misplaced = sources.flatMap((source) =>
398
+ source.misplaced.map((line) => ({ ...line, source })),
221
399
  );
400
+ const negations = sources.flatMap((source) => source.negations);
401
+
402
+ /*
403
+ * When nobody ignores it, the file to fix is the shared one when there is
404
+ * one — a workspace root covers every member, so that is where it belongs.
405
+ */
406
+ const target = sources.find((source) => !source.own) ?? sources[0];
407
+
222
408
  const tracked = trackedArtefacts(root);
223
409
 
224
410
  if (isFix) {
225
- const changed = misplaced.length > 0 || !isIgnored;
226
- if (changed) {
227
- writeFileSync(gitignorePath, rewrite(lines));
228
- stdout.write('.gitignore rewritten:\n');
229
- for (const line of misplaced) {
230
- stdout.write(` - ${line.pattern}\n`);
231
- }
232
- if (!isIgnored) {
233
- stdout.write(` + ${ARTIFACTS}/\n`);
234
- }
235
- for (const line of negations) {
236
- stdout.write(` · ${line.pattern} — a negation rescues a tracked file, kept\n`);
411
+ /*
412
+ * Fix repairs the package's OWN file only — it never creates one, and it
413
+ * never rewrites an ancestor, which is a different project's file.
414
+ */
415
+ const own = sources.find((source) => source.own);
416
+ if (own) {
417
+ const changed = own.misplaced.length > 0 || !own.isIgnored;
418
+ if (changed) {
419
+ writeFileSync(own.path, rewrite(own.lines));
420
+ stdout.write('.gitignore rewritten:\n');
421
+ for (const line of own.misplaced) {
422
+ stdout.write(` - ${line.pattern}\n`);
423
+ }
424
+ if (!own.isIgnored) {
425
+ stdout.write(` + ${ARTIFACTS}/\n`);
426
+ }
427
+ for (const line of own.negations) {
428
+ stdout.write(` · ${line.pattern} — a negation rescues a tracked file, kept\n`);
429
+ }
237
430
  }
238
431
  }
239
432
  } else if (misplaced.length > 0 || !isIgnored) {
240
433
  stdout.write('Artefacts belong under .artifacts/<tool>/ at the project root:\n');
241
434
  for (const line of misplaced) {
242
- stdout.write(` ✗ .gitignore names ${line.pattern} — its home is ${line.home}\n`);
435
+ stdout.write(` ✗ ${line.source.label} names ${line.pattern} — its home is ${line.home}\n`);
243
436
  }
244
437
  if (!isIgnored) {
245
- stdout.write(` ✗ .gitignore does not ignore ${ARTIFACTS}/ — add it\n`);
438
+ stdout.write(` ✗ ${target.label} does not ignore ${ARTIFACTS}/ — add it\n`);
246
439
  }
247
440
  for (const line of negations) {
248
441
  stdout.write(` · ${line.pattern} — a negation rescues a tracked file, left for you\n`);
@@ -6,6 +6,11 @@
6
6
  *
7
7
  * Usage: node merge-knip-config.js <base.json> [project-knip.json]
8
8
  *
9
+ * The project file is read as JSONC — comments and trailing commas — so an
10
+ * ignore can carry the reason it exists. Every entry knip is told to overlook
11
+ * is a judgement, and a judgement with no reason beside it is re-litigated by
12
+ * the next reader.
13
+ *
9
14
  * Merge rules:
10
15
  * - Arrays (ignoreDependencies, ignoreBinaries, ignore): concatenated and deduplicated
11
16
  * - Objects (rules, vitest, etc.): project values override base
@@ -29,14 +34,101 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
29
34
 
30
35
  import { workspaceMembers } from './workspace-members.js';
31
36
 
37
+ /**
38
+ * Drops `//` and block comments, leaving everything inside a string literal
39
+ * alone — a comment marker in a glob (`ignore: ["**\/*.js"]`) is data.
40
+ */
41
+ function withoutComments(text) {
42
+ let output = '',
43
+ index = 0,
44
+ inString = false;
45
+
46
+ while (index < text.length) {
47
+ const char = text[index];
48
+
49
+ if (inString) {
50
+ const escaped = char === '\\';
51
+ output += escaped ? char + (text[index + 1] ?? '') : char;
52
+ inString = escaped || char !== '"';
53
+ index += escaped ? 2 : 1;
54
+ } else if (char === '"') {
55
+ inString = true;
56
+ output += char;
57
+ index += 1;
58
+ } else if (char === '/' && text[index + 1] === '/') {
59
+ while (index < text.length && text[index] !== '\n') {
60
+ index += 1;
61
+ }
62
+ } else if (char === '/' && text[index + 1] === '*') {
63
+ const end = text.indexOf('*/', index + 2);
64
+ index = end === -1 ? text.length : end + 2;
65
+ } else {
66
+ output += char;
67
+ index += 1;
68
+ }
69
+ }
70
+
71
+ return output;
72
+ }
73
+
74
+ /** Whether the comma at `index` is the last one of its collection — `["a",]`. */
75
+ function closesCollection(text, index) {
76
+ if (text[index] !== ',') {
77
+ return false;
78
+ }
79
+
80
+ let next = index + 1;
81
+ while (next < text.length && text[next].trim() === '') {
82
+ next += 1;
83
+ }
84
+
85
+ return text[next] === ']' || text[next] === '}';
86
+ }
87
+
88
+ /** Drops a comma that closes its collection, outside string literals. */
89
+ function withoutTrailingCommas(text) {
90
+ let output = '',
91
+ index = 0,
92
+ inString = false;
93
+
94
+ while (index < text.length) {
95
+ const char = text[index];
96
+ const closes = closesCollection(text, index);
97
+
98
+ if (inString) {
99
+ const escaped = char === '\\';
100
+ output += escaped ? char + (text[index + 1] ?? '') : char;
101
+ inString = escaped || char !== '"';
102
+ index += escaped ? 2 : 1;
103
+ } else {
104
+ inString = char === '"';
105
+ output += closes ? '' : char;
106
+ index += 1;
107
+ }
108
+ }
109
+
110
+ return output;
111
+ }
112
+
113
+ /**
114
+ * Reads a JSONC document. Hand-written on purpose: no JSONC parser is in this
115
+ * package's tree, and two token classes do not earn a dependency.
116
+ */
117
+ function readJsonc(path) {
118
+ const document = readFileSync(path, 'utf8'),
119
+ json = withoutTrailingCommas(withoutComments(document));
120
+
121
+ return JSON.parse(json);
122
+ }
123
+
32
124
  const basePath = process.argv[2];
33
125
  const projectPath = process.argv[3];
34
126
 
35
- const base = JSON.parse(readFileSync(basePath, 'utf8'));
127
+ const base = readJsonc(basePath);
36
128
 
37
129
  let project = {};
38
130
  if (projectPath) {
39
- project = JSON.parse(readFileSync(projectPath, 'utf8'));
131
+ project = readJsonc(projectPath);
40
132
  }
41
133
 
42
134
  const ARRAY_KEYS = new Set(['ignore', 'ignoreBinaries', 'ignoreDependencies', 'entry', 'project']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/typescript",
3
- "version": "9.0.0",
3
+ "version": "9.1.0",
4
4
  "author": "Jean-Baptiste Terrazzoni <contact@jterrazz.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,6 +23,10 @@
23
23
  "types": "./src/index.d.ts",
24
24
  "default": "./src/index.js"
25
25
  },
26
+ "./oxfmt": {
27
+ "types": "./src/oxfmt.d.ts",
28
+ "default": "./src/oxfmt.js"
29
+ },
26
30
  "./oxlint": {
27
31
  "types": "./src/oxlint.d.ts",
28
32
  "default": "./src/oxlint.js"
package/src/oxfmt.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ type ConfigObject = Record<string, unknown>;
2
+
3
+ declare const base: ConfigObject;
4
+
5
+ export { defineConfig } from 'oxfmt';
6
+ export { base };
package/src/oxfmt.js ADDED
@@ -0,0 +1,14 @@
1
+ /*
2
+ * The tool-facing oxfmt entry (`@jterrazz/typescript/oxfmt`): the shared
3
+ * formatting preset plus oxfmt's own `defineConfig`, for the same reason
4
+ * `oxlint.js` re-exports its tool's — a consumer's config names this package
5
+ * and nothing else:
6
+ *
7
+ * import { base, defineConfig } from '@jterrazz/typescript/oxfmt';
8
+ *
9
+ * export default defineConfig(base);
10
+ */
11
+
12
+ export { defineConfig } from 'oxfmt';
13
+
14
+ export { default as base } from '../presets/oxfmt/index.js';
@@ -0,0 +1,17 @@
1
+ import { expect, test } from 'vitest';
2
+
3
+ import { base, defineConfig } from './oxfmt.js';
4
+
5
+ test('exports the shared formatting preset', () => {
6
+ // Given - the tool-facing entry
7
+ // Then - the preset carries the ecosystem's format: four spaces, single quotes, 100 columns
8
+ expect(base).toMatchObject({ printWidth: 100, singleQuote: true, tabWidth: 4 });
9
+ });
10
+
11
+ test("re-exports oxfmt's own defineConfig", () => {
12
+ // Given - a config a consumer would write, with oxfmt declared nowhere in its project
13
+ const config = { printWidth: 80 };
14
+
15
+ // Then - the entry carries the tool's helper, which returns the config unchanged
16
+ expect(defineConfig(config)).toBe(config);
17
+ });
package/src/oxlint.d.ts CHANGED
@@ -12,4 +12,5 @@ declare const node: ConfigObject;
12
12
  */
13
13
  declare function compose(...fragments: ConfigObject[]): ConfigObject;
14
14
 
15
+ export { defineConfig } from 'oxlint';
15
16
  export { compose, expo, hexagonal, next, node };
package/src/oxlint.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /*
2
2
  * The tool-facing oxlint entry (`@jterrazz/typescript/oxlint`): the named
3
- * presets plus the `compose()` helper. Wiring is EXPLICIT a consumer
4
- * composes exactly the fragments it wants, nothing is auto-detected:
3
+ * presets, the `compose()` helper, and oxlint's own `defineConfig`. Wiring is
4
+ * EXPLICIT — a consumer composes exactly the fragments it wants, nothing is
5
+ * auto-detected:
5
6
  *
6
7
  * import { testing } from '@jterrazz/test/oxlint';
7
8
  * import { compose, node } from '@jterrazz/typescript/oxlint';
@@ -50,6 +51,13 @@ export function compose(...fragments) {
50
51
  return merged;
51
52
  }
52
53
 
54
+ /* Oxlint's own `defineConfig`, re-exported from here: a bare
55
+ * `import { defineConfig } from 'oxlint'` in a consumer's config resolves only
56
+ * where the consumer declares oxlint itself, which pnpm's strict node_modules
57
+ * refuses to assume. Resolved from THIS package — where oxlint is a real
58
+ * dependency — the one-devDependency shape holds on every package manager. */
59
+ export { defineConfig } from 'oxlint';
60
+
53
61
  export { default as hexagonal } from '../presets/oxlint/architectures/hexagonal.js';
54
62
  export { default as expo } from '../presets/oxlint/expo.js';
55
63
  export { default as next } from '../presets/oxlint/next.js';
@@ -1,6 +1,6 @@
1
1
  import { expect, test } from 'vitest';
2
2
 
3
- import { compose, expo, hexagonal, next, node } from './oxlint.js';
3
+ import { compose, defineConfig, expo, hexagonal, next, node } from './oxlint.js';
4
4
 
5
5
  test('concatenates and dedupes plugin lists', () => {
6
6
  // Given - two fragments sharing one jsPlugin
@@ -77,3 +77,11 @@ test('exports the named presets', () => {
77
77
  expect(typeof preset).toBe('object');
78
78
  }
79
79
  });
80
+
81
+ test("re-exports oxlint's own defineConfig", () => {
82
+ // Given - a config a consumer would write, with oxlint declared nowhere in its project
83
+ const config = { rules: { curly: 'error' } };
84
+
85
+ // Then - the entry carries the tool's helper, which returns the config unchanged
86
+ expect(defineConfig(config)).toBe(config);
87
+ });