@tsrx/oxc 0.0.0-trusted-publishing-bootstrap → 0.8.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/THIRD_PARTY_NOTICES.md +49 -0
  4. package/bin/oxc-tsrx +2 -0
  5. package/bin/oxc-tsrx-fmt +2 -0
  6. package/bin/oxc-tsrx-lint +2 -0
  7. package/bin/oxc-tsrx-lsp +2 -0
  8. package/bin/oxfmt +2 -0
  9. package/bin/oxlint +2 -0
  10. package/dist/bin/oxc-tsrx-fmt.js +13 -0
  11. package/dist/bin/oxc-tsrx-lint.js +13 -0
  12. package/dist/bin/oxc-tsrx-lsp.js +13 -0
  13. package/dist/bin/oxc-tsrx.js +115 -0
  14. package/dist/bin/oxfmt.js +24 -0
  15. package/dist/bin/oxlint.js +33 -0
  16. package/dist/canonical-command.d.ts +50 -0
  17. package/dist/canonical-command.js +196 -0
  18. package/dist/compat.d.ts +149 -0
  19. package/dist/compat.js +1615 -0
  20. package/dist/editor-resolution.js +508 -0
  21. package/dist/format-cli.js +276 -0
  22. package/dist/format-invocation.js +97 -0
  23. package/dist/format.d.ts +1 -0
  24. package/dist/format.js +56 -0
  25. package/dist/index.d.ts +8 -0
  26. package/dist/index.js +16 -0
  27. package/dist/lint-cli.js +487 -0
  28. package/dist/lint-invocation.js +192 -0
  29. package/dist/lint-js-plugins.js +819 -0
  30. package/dist/lint-plugins-dev.d.ts +1 -0
  31. package/dist/lint-plugins-dev.js +2 -0
  32. package/dist/lint-prestart.js +16 -0
  33. package/dist/lint.d.ts +1 -0
  34. package/dist/lint.js +2 -0
  35. package/dist/native-targets.js +76 -0
  36. package/dist/oxlint-lsp-multiplexer.js +622 -0
  37. package/dist/package-binary.js +29 -0
  38. package/dist/parser.d.ts +216 -0
  39. package/dist/parser.js +557 -0
  40. package/dist/process.js +88 -0
  41. package/dist/provider-resolve.d.ts +160 -0
  42. package/dist/provider-resolve.js +471 -0
  43. package/dist/providers-report.js +49 -0
  44. package/dist/runtime.js +323 -0
  45. package/dist/spawn-command.d.ts +20 -0
  46. package/dist/spawn-command.js +87 -0
  47. package/dist/tsrx-core-compat/facade.js +1184 -0
  48. package/dist/tsrx-core-compat/index.d.ts +6 -0
  49. package/dist/tsrx-core-compat/index.js +9 -0
  50. package/dist/tsrx-core-compat/style.js +525 -0
  51. package/dist/tsrx-core-compat/types/estree.d.ts +20 -0
  52. package/dist/tsrx-core-compat/types/index.d.ts +50 -0
  53. package/dist/tsrx-transfer.js +352 -0
  54. package/package.json +144 -5
package/dist/compat.js ADDED
@@ -0,0 +1,1615 @@
1
+ import { isSpawnable, rejectConfiguredValue, resolveEditorLinter } from "./editor-resolution.js";
2
+ import { createRequire } from "node:module";
3
+ import { access, chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { homedir } from "node:os";
6
+ //#region src/compat.ts
7
+ const COMPATIBILITY_SCHEMA = 1;
8
+ /**
9
+ * The provider id, which is also this package's command name. It is written
10
+ * into every facade's `oxcTsrxCompatibility.provider`, compared against on the
11
+ * way back out, and named in the prose a user is told to type. It is *not* the
12
+ * npm package name and never was: renaming the package left the id alone so
13
+ * that facades a previous release wrote are still recognised as ours.
14
+ */
15
+ const PROVIDER = "oxc-tsrx";
16
+ /** The published npm package name, which is what resolution and install paths speak. */
17
+ const PACKAGE_NAME = "@tsrx/oxc";
18
+ /** `PACKAGE_NAME` as path segments: a scoped name is two directories under `node_modules`. */
19
+ const PACKAGE_DIRECTORY = Object.freeze(PACKAGE_NAME.split("/"));
20
+ const DIRECT_DEPENDENCY_FIELDS = [
21
+ "dependencies",
22
+ "devDependencies",
23
+ "optionalDependencies"
24
+ ];
25
+ const SLOTS = Object.freeze([
26
+ Object.freeze({
27
+ name: "oxc-parser",
28
+ capability: "parser",
29
+ exportPath: "@tsrx/oxc/parser",
30
+ binary: null
31
+ }),
32
+ Object.freeze({
33
+ name: "oxlint",
34
+ capability: "lint",
35
+ exportPath: "@tsrx/oxc/lint",
36
+ binary: "oxlint"
37
+ }),
38
+ Object.freeze({
39
+ name: "oxfmt",
40
+ capability: "format",
41
+ exportPath: "@tsrx/oxc/format",
42
+ binary: "oxfmt"
43
+ })
44
+ ]);
45
+ /**
46
+ * The fourth slot. It is not a package: it is one key in the user's own
47
+ * `.vscode/settings.json`, and it exists because `setup` fixing *package*
48
+ * resolution does not fix the editor. The official OXC extension finds its
49
+ * linter through `node_modules/.bin/oxlint`, and in a Vite+ project that shim
50
+ * belongs to Vite+, which knows nothing about `.tsrx`. The result is an editor
51
+ * with no diagnostics and nothing anywhere saying why.
52
+ *
53
+ * This is the one place `setup` writes outside `node_modules`, so every report
54
+ * names the file it touched.
55
+ */
56
+ const EDITOR_SLOT = Object.freeze({
57
+ name: "oxc.path.oxlint",
58
+ capability: "editor",
59
+ key: "oxc.path.oxlint",
60
+ directory: ".vscode",
61
+ file: "settings.json"
62
+ });
63
+ /** Where `setup` records what it did to the user's settings file. */
64
+ const EDITOR_RECEIPT = [".oxc-tsrx-compat", "editor-slot.json"];
65
+ /**
66
+ * The folder-scoping gap, which is the reason writing the key is not the same
67
+ * thing as wiring the editor.
68
+ *
69
+ * `setup` writes at the project root, meaning the nearest `package.json`. VS
70
+ * Code reads `.vscode/settings.json` only from a folder that is a workspace
71
+ * root, never from a subfolder of one. Every monorepo and every nested app puts
72
+ * a workspace root *above* the project root, and in that window the key that was
73
+ * written is simply not read: the extension auto-detects, finds whichever tool
74
+ * owns `node_modules/.bin/oxlint`, and says nothing.
75
+ *
76
+ * Nothing here writes into an ancestor on its own. A relative value is joined
77
+ * onto the window's *first* folder rather than onto the folder holding the
78
+ * settings file, the extension rejects any value containing `..`, and a
79
+ * configured value replaces its own lookup with no fallback, so a value written
80
+ * for a folder the user did not open leaves the linter dead rather than
81
+ * degrading to auto-detection. Guessing wrong is strictly worse than not
82
+ * guessing. So the ancestors are named, the evidence that made each one a
83
+ * candidate is named with it, and `setup --workspace-root <dir>` is the single
84
+ * explicit way to write above the project root.
85
+ *
86
+ * The order below is the order the evidence is trusted in, most deliberate
87
+ * first: a `.code-workspace` file is someone stating the root outright, a
88
+ * `.git` directory is the weakest hint and comes last.
89
+ */
90
+ const WORKSPACE_ROOT_EVIDENCE = Object.freeze([
91
+ "pnpm-workspace.yaml",
92
+ "package.json",
93
+ "turbo.json",
94
+ "nx.json",
95
+ "lerna.json",
96
+ ".git",
97
+ "package-lock.json",
98
+ "pnpm-lock.yaml",
99
+ "yarn.lock",
100
+ "bun.lock",
101
+ "bun.lockb",
102
+ "npm-shrinkwrap.json",
103
+ "node_modules"
104
+ ]);
105
+ const LOCKFILE_EVIDENCE = Object.freeze([
106
+ "package-lock.json",
107
+ "pnpm-lock.yaml",
108
+ "yarn.lock",
109
+ "bun.lock",
110
+ "bun.lockb",
111
+ "npm-shrinkwrap.json"
112
+ ]);
113
+ const CODE_WORKSPACE_SUFFIX = ".code-workspace";
114
+ function evidenceRank(evidence) {
115
+ if (evidence.endsWith(CODE_WORKSPACE_SUFFIX)) return -1;
116
+ return WORKSPACE_ROOT_EVIDENCE.indexOf(evidence);
117
+ }
118
+ /**
119
+ * The single file in `directory` that makes it look like a workspace root, or
120
+ * `null`. Only the strongest one is reported: a repository root usually carries
121
+ * three or four of these and listing them all buries the folder itself.
122
+ */
123
+ async function workspaceRootEvidence(directory) {
124
+ const declared = (await readdir(directory).catch(() => [])).filter((name) => name.endsWith(CODE_WORKSPACE_SUFFIX)).sort();
125
+ if (declared.length > 0) return declared[0];
126
+ if (await exists(join(directory, "pnpm-workspace.yaml"))) return "pnpm-workspace.yaml";
127
+ const manifest = await readJson(join(directory, "package.json")).catch(() => null);
128
+ if (manifest && manifest.workspaces !== void 0) return "package.json";
129
+ for (const name of [
130
+ "turbo.json",
131
+ "nx.json",
132
+ "lerna.json",
133
+ ".git"
134
+ ]) if (await exists(join(directory, name))) return name;
135
+ if (manifest) {
136
+ for (const name of LOCKFILE_EVIDENCE) if (await exists(join(directory, name))) return name;
137
+ if (await exists(join(directory, "node_modules"))) return "node_modules";
138
+ }
139
+ return null;
140
+ }
141
+ /**
142
+ * Every folder strictly above `root` that looks like a workspace root, ordered
143
+ * by how deliberate the evidence is and then nearest first.
144
+ *
145
+ * The walk stops at the user's home directory rather than at the filesystem
146
+ * root. A dotfiles repository puts `.git` in `$HOME`, and reporting `$HOME` as a
147
+ * candidate workspace root for every project on the machine would turn this
148
+ * detection into noise that readers learn to skip.
149
+ */
150
+ async function candidateWorkspaceRoots(root) {
151
+ const home = homedir();
152
+ const candidates = [];
153
+ let directory = dirname(root);
154
+ while (directory !== dirname(directory)) {
155
+ if (directory === home) break;
156
+ const evidence = await workspaceRootEvidence(directory);
157
+ if (evidence) candidates.push({
158
+ path: directory,
159
+ evidence
160
+ });
161
+ directory = dirname(directory);
162
+ }
163
+ return candidates.sort((left, right) => evidenceRank(left.evidence) - evidenceRank(right.evidence) || right.path.length - left.path.length);
164
+ }
165
+ /**
166
+ * TSRX editor support that this package deliberately does not own. `.tsrx` as a
167
+ * *language* belongs to the TSRX toolchain, so `setup` detects and reports these
168
+ * and changes none of them.
169
+ */
170
+ const TSRX_TYPESCRIPT_PLUGIN = "@tsrx/typescript-plugin";
171
+ const TSRX_FRAMEWORK_BINDINGS = Object.freeze([
172
+ "@tsrx/react",
173
+ "@tsrx/vue",
174
+ "@tsrx/solid",
175
+ "@tsrx/preact",
176
+ "@tsrx/ripple",
177
+ "octane"
178
+ ]);
179
+ /**
180
+ * `@tsrx/typescript-plugin` declares `peerDependencies.typescript: ^5.9.3`, and
181
+ * `vp create` scaffolds TypeScript 6, so a stock Vite+ project sits outside the
182
+ * plugin's supported range. That is a fact from the plugin's own manifest.
183
+ *
184
+ * What that mismatch actually causes is NOT asserted here. A stock scaffold with
185
+ * TypeScript 6.0.3 was measured answering `hover: const legacy: number` three
186
+ * times out of three, so this is reported as an unsupported combination rather
187
+ * than as a known failure. Nothing here changes the version.
188
+ */
189
+ const TYPESCRIPT_REQUIREMENT = ">=5.9 <6";
190
+ async function exists(path) {
191
+ try {
192
+ await access(path);
193
+ return true;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+ async function readJson(path) {
199
+ return JSON.parse(await readFile(path, "utf8"));
200
+ }
201
+ function toPosix(path) {
202
+ return sep === "/" ? path : path.replaceAll(sep, "/");
203
+ }
204
+ function within(root, candidate) {
205
+ const offset = relative(root, candidate);
206
+ return offset !== ".." && !offset.startsWith(`..${sep}`) && !isAbsolute(offset);
207
+ }
208
+ async function realPathOrNull(path) {
209
+ try {
210
+ return await realpath(path);
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+ const JSONC_PUNCTUATION = /* @__PURE__ */ new Set([
216
+ "{",
217
+ "}",
218
+ "[",
219
+ "]",
220
+ ",",
221
+ ":"
222
+ ]);
223
+ function tokenizeJsonc(text) {
224
+ const tokens = [];
225
+ let index = 0;
226
+ while (index < text.length) {
227
+ const character = text[index];
228
+ if (character === "\"") {
229
+ let cursor = index + 1;
230
+ let closed = false;
231
+ while (cursor < text.length) {
232
+ if (text[cursor] === "\\") {
233
+ cursor += 2;
234
+ continue;
235
+ }
236
+ if (text[cursor] === "\"") {
237
+ closed = true;
238
+ break;
239
+ }
240
+ if (text[cursor] === "\n") break;
241
+ cursor += 1;
242
+ }
243
+ if (!closed) return null;
244
+ tokens.push({
245
+ kind: "string",
246
+ start: index,
247
+ end: cursor + 1,
248
+ text: text.slice(index, cursor + 1)
249
+ });
250
+ index = cursor + 1;
251
+ continue;
252
+ }
253
+ if (character === "/" && text[index + 1] === "/") {
254
+ const newline = text.indexOf("\n", index);
255
+ index = newline === -1 ? text.length : newline;
256
+ continue;
257
+ }
258
+ if (character === "/" && text[index + 1] === "*") {
259
+ const close = text.indexOf("*/", index + 2);
260
+ if (close === -1) return null;
261
+ index = close + 2;
262
+ continue;
263
+ }
264
+ if (JSONC_PUNCTUATION.has(character)) {
265
+ tokens.push({
266
+ kind: character,
267
+ start: index,
268
+ end: index + 1,
269
+ text: character
270
+ });
271
+ index += 1;
272
+ continue;
273
+ }
274
+ if (/\s/u.test(character)) {
275
+ index += 1;
276
+ continue;
277
+ }
278
+ let cursor = index;
279
+ while (cursor < text.length && !/[\s{}[\],:"]/u.test(text[cursor]) && !(text[cursor] === "/" && (text[cursor + 1] === "/" || text[cursor + 1] === "*"))) cursor += 1;
280
+ if (cursor === index) return null;
281
+ tokens.push({
282
+ kind: "literal",
283
+ start: index,
284
+ end: cursor,
285
+ text: text.slice(index, cursor)
286
+ });
287
+ index = cursor;
288
+ }
289
+ return tokens;
290
+ }
291
+ /** Comments and trailing commas removed, so `JSON.parse` can read the rest. */
292
+ function stripJsonc(text) {
293
+ const tokens = tokenizeJsonc(text);
294
+ if (!tokens) return null;
295
+ let output = "";
296
+ for (let index = 0; index < tokens.length; index += 1) {
297
+ const token = tokens[index];
298
+ if (token.kind === ",") {
299
+ const next = tokens[index + 1];
300
+ if (next && (next.kind === "}" || next.kind === "]")) continue;
301
+ }
302
+ output += token.text;
303
+ }
304
+ return output;
305
+ }
306
+ function parseJsoncValue(text) {
307
+ const stripped = stripJsonc(text);
308
+ if (stripped === null) return null;
309
+ try {
310
+ return JSON.parse(stripped);
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+ /**
316
+ * Every top-level entry of the document's object, with byte offsets. Returns
317
+ * `null` for anything that is not a single top-level object, which is the shape
318
+ * both settings files always have and the only shape this package will edit.
319
+ */
320
+ function readObjectAt(tokens, start) {
321
+ if (!tokens || tokens[start]?.kind !== "{") return null;
322
+ const entries = [];
323
+ let position = start + 1;
324
+ while (position < tokens.length && tokens[position].kind !== "}") {
325
+ const key = tokens[position];
326
+ if (key.kind !== "string" || tokens[position + 1]?.kind !== ":") return null;
327
+ const valueStart = position + 2;
328
+ if (valueStart >= tokens.length) return null;
329
+ let depth = 0;
330
+ let valueEnd = -1;
331
+ for (let scan = valueStart; scan < tokens.length; scan += 1) {
332
+ const token = tokens[scan];
333
+ if (token.kind === "{" || token.kind === "[") depth += 1;
334
+ else if (token.kind === "}" || token.kind === "]") {
335
+ depth -= 1;
336
+ if (depth < 0) return null;
337
+ }
338
+ if (depth === 0) {
339
+ valueEnd = scan;
340
+ break;
341
+ }
342
+ }
343
+ if (valueEnd === -1) return null;
344
+ const comma = tokens[valueEnd + 1]?.kind === "," ? tokens[valueEnd + 1] : null;
345
+ if (!comma && tokens[valueEnd + 1]?.kind !== "}") return null;
346
+ let name;
347
+ try {
348
+ name = JSON.parse(key.text);
349
+ } catch {
350
+ return null;
351
+ }
352
+ entries.push({
353
+ key: name,
354
+ keyStart: key.start,
355
+ valueStart: tokens[valueStart].start,
356
+ valueEnd: tokens[valueEnd].end,
357
+ valueTokens: tokens.slice(valueStart, valueEnd + 1),
358
+ valueStartToken: valueStart,
359
+ valueEndToken: valueEnd,
360
+ commaEnd: comma ? comma.end : null
361
+ });
362
+ position = comma ? valueEnd + 2 : valueEnd + 1;
363
+ }
364
+ if (tokens[position]?.kind !== "}") return null;
365
+ return {
366
+ entries,
367
+ openEnd: tokens[start].end,
368
+ closeStart: tokens[position].start,
369
+ endToken: position
370
+ };
371
+ }
372
+ function readTopLevelObject(text) {
373
+ const tokens = tokenizeJsonc(text);
374
+ if (!tokens || tokens.length === 0) return null;
375
+ const object = readObjectAt(tokens, 0);
376
+ return object && object.endToken === tokens.length - 1 ? object : null;
377
+ }
378
+ function readCompilerOptions(text) {
379
+ const tokens = tokenizeJsonc(text);
380
+ if (!tokens || tokens.length === 0) return null;
381
+ const root = readObjectAt(tokens, 0);
382
+ if (!root || root.endToken !== tokens.length - 1) return null;
383
+ const entry = root.entries.find((candidate) => candidate.key === "compilerOptions");
384
+ if (!entry) return null;
385
+ const object = readObjectAt(tokens, entry.valueStartToken);
386
+ return object && object.endToken === entry.valueEndToken ? object : null;
387
+ }
388
+ function stringEntryValue(entry) {
389
+ if (entry.valueTokens.length !== 1 || entry.valueTokens[0].kind !== "string") return null;
390
+ try {
391
+ return JSON.parse(entry.valueTokens[0].text);
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+ function detectIndent(text, structure) {
397
+ const anchor = structure.entries[0]?.keyStart;
398
+ if (anchor === void 0) return " ";
399
+ const lineStart = text.lastIndexOf("\n", anchor - 1) + 1;
400
+ const prefix = text.slice(lineStart, anchor);
401
+ return prefix.length > 0 && /^[\t ]*$/u.test(prefix) ? prefix : " ";
402
+ }
403
+ function insertTopLevelEntry(text, structure, key, value) {
404
+ return insertObjectEntry(text, structure, key, JSON.stringify(value));
405
+ }
406
+ function insertObjectEntry(text, structure, key, rawValue) {
407
+ const indent = detectIndent(text, structure);
408
+ const literal = `${JSON.stringify(key)}: ${rawValue}`;
409
+ if (structure.entries.length === 0) {
410
+ if (text.slice(structure.openEnd, structure.closeStart).trim().length === 0) return `${text.slice(0, structure.openEnd)}\n${indent}${literal}\n${text.slice(structure.closeStart)}`;
411
+ }
412
+ const separator = structure.entries.length > 0 ? "," : "";
413
+ return `${text.slice(0, structure.openEnd)}\n${indent}${literal}${separator}${text.slice(structure.openEnd)}`;
414
+ }
415
+ function removeTopLevelEntry(text, structure, key) {
416
+ const index = structure.entries.findIndex((entry) => entry.key === key);
417
+ if (index === -1) return text;
418
+ const entry = structure.entries[index];
419
+ let start = entry.keyStart;
420
+ let end = entry.commaEnd ?? entry.valueEnd;
421
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
422
+ if (/^[\t ]*$/u.test(text.slice(lineStart, start))) start = lineStart;
423
+ while (end < text.length && (text[end] === " " || text[end] === " ")) end += 1;
424
+ if (text[end] === "\r") end += 1;
425
+ if (text[end] === "\n") end += 1;
426
+ const output = text.slice(0, start) + text.slice(end);
427
+ if (entry.commaEnd === null && index > 0) {
428
+ const comma = structure.entries[index - 1].commaEnd;
429
+ if (comma !== null && comma <= start) return output.slice(0, comma - 1) + output.slice(comma);
430
+ }
431
+ return output;
432
+ }
433
+ async function findProjectRoot(start = process.cwd()) {
434
+ let directory = resolve(start);
435
+ try {
436
+ if (!(await lstat(directory)).isDirectory()) directory = dirname(directory);
437
+ } catch {
438
+ throw new Error(`project path does not exist: ${directory}`);
439
+ }
440
+ for (;;) {
441
+ if (await exists(join(directory, "package.json"))) return directory;
442
+ const parent = dirname(directory);
443
+ if (parent === directory) throw new Error(`no package.json was found at or above ${resolve(start)}; run oxc-tsrx from your project root, or pass --project <directory>`);
444
+ directory = parent;
445
+ }
446
+ }
447
+ async function detectPackageManager(projectRoot, userAgent = process.env.npm_config_user_agent) {
448
+ for (const [lockfile, manager] of [
449
+ ["pnpm-lock.yaml", "pnpm"],
450
+ ["bun.lock", "bun"],
451
+ ["bun.lockb", "bun"],
452
+ ["yarn.lock", "yarn"],
453
+ ["package-lock.json", "npm"],
454
+ ["deno.lock", "deno"],
455
+ ["deno.json", "deno"],
456
+ ["deno.jsonc", "deno"]
457
+ ]) if (await exists(join(projectRoot, lockfile))) return manager;
458
+ const agent = userAgent?.split("/")[0];
459
+ if ([
460
+ "npm",
461
+ "pnpm",
462
+ "yarn",
463
+ "bun",
464
+ "deno"
465
+ ].includes(agent)) return agent;
466
+ return "unknown";
467
+ }
468
+ function providerSelection(manifest) {
469
+ return DIRECT_DEPENDENCY_FIELDS.find((field) => typeof manifest[field]?.[PACKAGE_NAME] === "string");
470
+ }
471
+ function directlySelected(manifest, packageName) {
472
+ return DIRECT_DEPENDENCY_FIELDS.some((field) => typeof manifest[field]?.[packageName] === "string");
473
+ }
474
+ function compatibilityMetadata(manifest) {
475
+ const metadata = manifest?.oxcTsrxCompatibility;
476
+ if (metadata?.schemaVersion === COMPATIBILITY_SCHEMA && metadata?.provider === PROVIDER && typeof metadata.providerVersion === "string") return metadata;
477
+ return null;
478
+ }
479
+ async function installedProvider(projectRoot) {
480
+ const projectManifestPath = join(projectRoot, "package.json");
481
+ const projectManifest = await readJson(projectManifestPath);
482
+ const selectedFrom = providerSelection(projectManifest);
483
+ if (!selectedFrom) throw new Error(`${PACKAGE_NAME} must be a direct dependency or devDependency in ${projectManifestPath}`);
484
+ const require = createRequire(projectManifestPath);
485
+ let providerManifestPath;
486
+ try {
487
+ providerManifestPath = require.resolve(`${PACKAGE_NAME}/package.json`);
488
+ } catch {
489
+ throw new Error(`${PACKAGE_NAME} is declared but not installed under ${projectRoot}; install dependencies first`);
490
+ }
491
+ const manifest = await readJson(providerManifestPath);
492
+ if (manifest.name !== PACKAGE_NAME || typeof manifest.version !== "string") throw new Error(`resolved ${providerManifestPath} is not a valid ${PACKAGE_NAME} package`);
493
+ return {
494
+ manifest,
495
+ manifestPath: providerManifestPath,
496
+ projectManifest,
497
+ root: dirname(providerManifestPath),
498
+ selectedFrom
499
+ };
500
+ }
501
+ function facadeManifest(slot, providerVersion, replacedPackage) {
502
+ const manifest = {
503
+ name: slot.name,
504
+ version: providerVersion,
505
+ private: true,
506
+ description: `${slot.name} compatibility facade generated by ${PROVIDER}`,
507
+ type: "module",
508
+ main: "./dist/index.js",
509
+ types: "./dist/index.d.ts",
510
+ exports: {
511
+ ".": {
512
+ types: "./dist/index.d.ts",
513
+ import: "./dist/index.js",
514
+ default: "./dist/index.js"
515
+ },
516
+ "./package.json": "./package.json"
517
+ },
518
+ oxcTsrxCompatibility: {
519
+ schemaVersion: COMPATIBILITY_SCHEMA,
520
+ provider: PROVIDER,
521
+ providerVersion,
522
+ capability: slot.capability,
523
+ ...replacedPackage ? { replacedPackage } : {}
524
+ }
525
+ };
526
+ if (slot.binary) manifest.bin = { [slot.binary]: `./bin/${slot.binary}` };
527
+ return manifest;
528
+ }
529
+ function binarySource(binary) {
530
+ return `#!/usr/bin/env node
531
+
532
+ import { createRequire } from "node:module";
533
+ import { dirname, resolve } from "node:path";
534
+ import { pathToFileURL } from "node:url";
535
+
536
+ try {
537
+ const require = createRequire(import.meta.url);
538
+ const manifestPath = require.resolve("@tsrx/oxc/package.json");
539
+ const manifest = require(manifestPath);
540
+ const declared = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[${JSON.stringify(binary)}];
541
+ if (typeof declared !== "string" || declared.length === 0) {
542
+ throw new Error("@tsrx/oxc does not declare the ${binary} binary");
543
+ }
544
+ await import(pathToFileURL(resolve(dirname(manifestPath), declared)).href);
545
+ } catch (error) {
546
+ console.error("${binary} (oxc-tsrx compatibility): " + (error instanceof Error ? error.message : String(error)));
547
+ process.exitCode = 2;
548
+ }
549
+ `;
550
+ }
551
+ function backupPath(modules, slot) {
552
+ return join(modules, ".oxc-tsrx-compat", "originals", slot.name.replaceAll("/", "__"));
553
+ }
554
+ async function inspectSlot(modules, slot, providerVersion, projectManifest) {
555
+ const destination = join(modules, ...slot.name.split("/"));
556
+ if (!await exists(destination)) return {
557
+ slot,
558
+ destination,
559
+ state: "missing",
560
+ metadata: null
561
+ };
562
+ const manifest = await readJson(join(destination, "package.json")).catch(() => null);
563
+ const metadata = compatibilityMetadata(manifest);
564
+ if (!metadata || metadata.capability !== slot.capability) {
565
+ if (manifest?.name === slot.name && typeof manifest.version === "string" && !directlySelected(projectManifest, slot.name)) {
566
+ if (await exists(backupPath(modules, slot))) return {
567
+ slot,
568
+ destination,
569
+ state: "collision",
570
+ metadata: null
571
+ };
572
+ return {
573
+ slot,
574
+ destination,
575
+ state: "replaceable",
576
+ metadata: null,
577
+ replacedPackage: {
578
+ name: manifest.name,
579
+ version: manifest.version
580
+ }
581
+ };
582
+ }
583
+ return {
584
+ slot,
585
+ destination,
586
+ state: "collision",
587
+ metadata: null
588
+ };
589
+ }
590
+ if (metadata.replacedPackage && !await exists(backupPath(modules, slot))) return {
591
+ slot,
592
+ destination,
593
+ state: "collision",
594
+ metadata: null
595
+ };
596
+ return {
597
+ slot,
598
+ destination,
599
+ state: metadata.providerVersion === providerVersion ? "active" : "stale",
600
+ metadata,
601
+ replacedPackage: metadata.replacedPackage ?? null
602
+ };
603
+ }
604
+ async function writeFacade(directory, slot, providerVersion, replacedPackage) {
605
+ await mkdir(join(directory, "dist"), { recursive: true });
606
+ await Promise.all([
607
+ writeFile(join(directory, "package.json"), `${JSON.stringify(facadeManifest(slot, providerVersion, replacedPackage), null, 2)}\n`),
608
+ writeFile(join(directory, "dist/index.js"), `export * from ${JSON.stringify(slot.exportPath)};\n`),
609
+ writeFile(join(directory, "dist/index.d.ts"), `export * from ${JSON.stringify(slot.exportPath)};\n`)
610
+ ]);
611
+ if (slot.binary) {
612
+ const binDirectory = join(directory, "bin");
613
+ const bin = join(binDirectory, slot.binary);
614
+ await mkdir(binDirectory, { recursive: true });
615
+ await writeFile(bin, binarySource(slot.binary), { mode: 493 });
616
+ await chmod(bin, 493);
617
+ }
618
+ }
619
+ async function replaceOwnedFacade(status, providerVersion, modules) {
620
+ const { destination, slot } = status;
621
+ const parent = dirname(destination);
622
+ const temporary = join(parent, `.oxc-tsrx-${slot.name}-new-${process.pid}`);
623
+ const previous = join(parent, `.oxc-tsrx-${slot.name}-old-${process.pid}`);
624
+ await rm(temporary, {
625
+ recursive: true,
626
+ force: true
627
+ });
628
+ await rm(previous, {
629
+ recursive: true,
630
+ force: true
631
+ });
632
+ await writeFacade(temporary, slot, providerVersion, status.replacedPackage);
633
+ if (status.state === "replaceable") {
634
+ const backup = backupPath(modules, slot);
635
+ if (await exists(backup)) throw new Error(`refusing to replace ${slot.name}: preserved package already exists at ${backup}`);
636
+ await mkdir(dirname(backup), { recursive: true });
637
+ await rename(destination, backup);
638
+ try {
639
+ await rename(temporary, destination);
640
+ } catch (error) {
641
+ await rename(backup, destination);
642
+ throw error;
643
+ }
644
+ return;
645
+ }
646
+ if (status.state === "stale") {
647
+ await rename(destination, previous);
648
+ try {
649
+ await rename(temporary, destination);
650
+ } catch (error) {
651
+ await rename(previous, destination);
652
+ throw error;
653
+ }
654
+ await rm(previous, {
655
+ recursive: true,
656
+ force: true
657
+ });
658
+ } else await rename(temporary, destination);
659
+ }
660
+ /**
661
+ * A launcher that names this package's binary in its own text.
662
+ *
663
+ * pnpm 10 writes `node_modules/.bin/<name>` as a shell script rather than a
664
+ * symlink, and npm and pnpm both write `.cmd` and `.ps1` launchers on Windows.
665
+ * For all of those the file that stats *is* the launcher, so nothing above it
666
+ * belongs to this package and its target is only readable inline. Measured on a
667
+ * real pnpm install: the shim is a script whose `exec` line names
668
+ * `@tsrx/oxc/bin/oxlint`.
669
+ *
670
+ * Both spellings are accepted. The package moved from `oxc-tsrx` to
671
+ * `@tsrx/oxc`, and an upgrade in place leaves the old launcher sitting in
672
+ * `.bin` until the next full install rewrites it. That launcher still runs this
673
+ * package, so reading it as foreign would make every report cry wolf at a tree
674
+ * that is correctly wired - which is the exact failure this text match exists
675
+ * to prevent.
676
+ */
677
+ const PROVIDER_LAUNCHER_TEXT = /(?:@tsrx[\\/]oxc|oxc-tsrx)[\\/]bin[\\/]oxlint/u;
678
+ /** Nothing a package manager writes into `.bin` is anywhere near this big. */
679
+ const LAUNCHER_TEXT_LIMIT = 65536;
680
+ /**
681
+ * Does running this path end up in this package?
682
+ *
683
+ * Three readings, because three package managers answer differently and only
684
+ * one of them can be answered by `realpath`: inside the installed package,
685
+ * inside the compatibility facade this package generated, or a text launcher
686
+ * that names the package's binary inline. The last one is why this exists: a
687
+ * pnpm shim that *is* ours resolves to a file under `.bin`, whose nearest
688
+ * `package.json` is the consumer's own, so every path-shaped test calls it
689
+ * foreign and a report built on that would cry wolf at a correctly wired tree.
690
+ */
691
+ async function leadsIntoProvider(candidate, providerReal, facadeReal) {
692
+ if (!candidate) return false;
693
+ const real = await realPathOrNull(candidate) ?? candidate;
694
+ if (within(providerReal, real)) return true;
695
+ if (facadeReal && within(facadeReal, real)) return true;
696
+ const info = await lstat(real).catch(() => null);
697
+ if (!info?.isFile() || info.size > LAUNCHER_TEXT_LIMIT) return false;
698
+ return PROVIDER_LAUNCHER_TEXT.test(await readFile(real, "utf8").catch(() => ""));
699
+ }
700
+ /**
701
+ * The official OXC extension resolves its linter through
702
+ * `node_modules/.bin/oxlint`. If that shim already lands inside this package
703
+ * there is nothing to write and the slot is reported `unnecessary`; if another
704
+ * tool owns it — Vite+ is the case this exists for — the setting is the only
705
+ * thing that reaches the editor.
706
+ *
707
+ * Resolution differs per package manager, so this reads the shim three ways.
708
+ * npm, pnpm, Yarn (node-modules linker) and Bun all publish a POSIX symlink,
709
+ * which `realpath` answers directly. npm and pnpm on Windows publish `.cmd` and
710
+ * `.ps1` text shims that name their target inline, which the text check reads.
711
+ * Anything that classifies as neither is reported `unknown` and treated as *not*
712
+ * ours, because writing the setting when it was not needed still points the
713
+ * extension at the right binary, while skipping it when it was needed is the
714
+ * silent dead editor this slot exists to prevent.
715
+ */
716
+ async function inspectLinterShim(modules, providerRoot) {
717
+ const binDirectory = join(modules, ".bin");
718
+ const names = process.platform === "win32" ? [
719
+ "oxlint.cmd",
720
+ "oxlint.ps1",
721
+ "oxlint"
722
+ ] : ["oxlint"];
723
+ const providerReal = await realPathOrNull(providerRoot) ?? providerRoot;
724
+ const facadeReal = await realPathOrNull(join(modules, "oxlint"));
725
+ const facadeIsOurs = facadeReal ? Boolean(compatibilityMetadata(await readJson(join(modules, "oxlint", "package.json")).catch(() => null))) : false;
726
+ for (const name of names) {
727
+ const shim = join(binDirectory, name);
728
+ const info = await lstat(shim).catch(() => null);
729
+ if (!info) continue;
730
+ const target = await realPathOrNull(shim);
731
+ if (target && within(providerReal, target)) return {
732
+ path: shim,
733
+ target,
734
+ owner: PACKAGE_NAME,
735
+ resolvedBy: "symlink"
736
+ };
737
+ if (target && facadeIsOurs && facadeReal && within(facadeReal, target)) return {
738
+ path: shim,
739
+ target,
740
+ owner: PACKAGE_NAME,
741
+ resolvedBy: "compatibility-facade"
742
+ };
743
+ if (info.isFile() && !info.isSymbolicLink()) {
744
+ const source = await readFile(shim, "utf8").catch(() => "");
745
+ if (PROVIDER_LAUNCHER_TEXT.test(source)) return {
746
+ path: shim,
747
+ target: target ?? null,
748
+ owner: PACKAGE_NAME,
749
+ resolvedBy: "shim-text"
750
+ };
751
+ return {
752
+ path: shim,
753
+ target: target ?? null,
754
+ owner: "other",
755
+ resolvedBy: "shim-text"
756
+ };
757
+ }
758
+ return {
759
+ path: shim,
760
+ target: target ?? null,
761
+ owner: target ? "other" : "unknown",
762
+ resolvedBy: target ? "symlink" : "unresolved"
763
+ };
764
+ }
765
+ return {
766
+ path: join(binDirectory, "oxlint"),
767
+ target: null,
768
+ owner: "none",
769
+ resolvedBy: "absent"
770
+ };
771
+ }
772
+ /**
773
+ * The value to write, always relative to the folder the settings file sits in,
774
+ * because that is the folder the editor has to have open for the file to be read
775
+ * at all. With no `--workspace-root` that folder is the project root and this is
776
+ * the installed copy at `node_modules/@tsrx/oxc/bin/oxlint`.
777
+ */
778
+ async function editorSettingValue(settingsRoot, projectRoot, providerRoot) {
779
+ const linked = join(projectRoot, "node_modules", ...PACKAGE_DIRECTORY, "bin", "oxlint");
780
+ if (await exists(linked)) return toPosix(relative(settingsRoot, linked));
781
+ const offset = relative(settingsRoot, join(providerRoot, "bin", "oxlint"));
782
+ return offset.startsWith("..") || isAbsolute(offset) ? join(providerRoot, "bin", "oxlint") : toPosix(offset);
783
+ }
784
+ async function readEditorReceipt(modules) {
785
+ const receipt = await readJson(join(modules, ...EDITOR_RECEIPT)).catch(() => null);
786
+ if (receipt?.schemaVersion === COMPATIBILITY_SCHEMA && receipt?.provider === PROVIDER && receipt?.key === EDITOR_SLOT.key) return receipt;
787
+ return null;
788
+ }
789
+ /**
790
+ * The durable form of the receipt: the key already sitting in a settings file.
791
+ *
792
+ * The receipt lives under `node_modules`, and every reinstall wipes it - which
793
+ * is routine, because the walkthroughs themselves tell readers to reinstall and
794
+ * re-run `setup`. The `.vscode` key survives the wipe, so when the receipt is
795
+ * gone the project root and every candidate workspace root are asked directly:
796
+ * a settings file whose key resolves back into THIS project's installed copy is
797
+ * a placement a previous `setup` made, and plain `setup`/`remove` keep serving
798
+ * it instead of silently reverting to the project root and leaving the old key
799
+ * behind in a file nothing takes back. A key resolving anywhere else is someone
800
+ * else's wiring and is left alone. Nearest placement wins: the project root is
801
+ * checked before any ancestor.
802
+ */
803
+ async function recoverWrittenSettingsRoot(projectRoot) {
804
+ const candidates = [projectRoot, ...(await candidateWorkspaceRoots(projectRoot)).map((candidate) => candidate.path)];
805
+ const installed = join(projectRoot, "node_modules", ...PACKAGE_DIRECTORY);
806
+ for (const directory of candidates) {
807
+ const value = (await readJson(join(directory, EDITOR_SLOT.directory, EDITOR_SLOT.file)).catch(() => null))?.[EDITOR_SLOT.key];
808
+ if (typeof value !== "string" || value.length === 0) continue;
809
+ const target = isAbsolute(value) ? value : resolve(directory, value);
810
+ const offset = relative(installed, target);
811
+ if (!offset.startsWith("..") && !isAbsolute(offset)) return directory;
812
+ }
813
+ return null;
814
+ }
815
+ /**
816
+ * Which folder's `.vscode/settings.json` this run is talking about.
817
+ *
818
+ * An explicit `--workspace-root` wins, then whatever a previous `setup`
819
+ * recorded, then the project root. The receipt is what keeps `remove` symmetric
820
+ * across a `--workspace-root` write: it stores the settings file relative to the
821
+ * project root, so `../../.vscode/settings.json` still finds its way home.
822
+ *
823
+ * Naming a *different* folder than the one already written to is refused rather
824
+ * than obeyed, because obeying it would rewrite the receipt and orphan the key
825
+ * that is already out there in a file nothing would take back.
826
+ */
827
+ async function editorSettingsRoot(projectRoot, receipt, workspaceRoot) {
828
+ const written = receipt?.settingsPath ? dirname(dirname(resolve(projectRoot, receipt.settingsPath))) : await recoverWrittenSettingsRoot(projectRoot);
829
+ if (workspaceRoot === void 0 || workspaceRoot === null) return written ?? projectRoot;
830
+ const named = resolve(workspaceRoot);
831
+ if (!(await lstat(named).catch(() => null))?.isDirectory()) throw new Error(`--workspace-root ${named} is not a directory`);
832
+ if (!within(named, projectRoot)) throw new Error(`--workspace-root ${named} does not contain ${projectRoot}. The editor resolves a relative "${EDITOR_SLOT.key}" against the folder you open, and the official OXC extension rejects any value containing "..", so the folder has to be at or above your project root`);
833
+ if (written && written !== named) throw new Error(`${PROVIDER} already wrote "${EDITOR_SLOT.key}" into ${join(written, EDITOR_SLOT.directory, EDITOR_SLOT.file)}. Run ${PROVIDER} remove first, then setup --workspace-root ${named}, so the key is never left behind in a file nothing takes back`);
834
+ return named;
835
+ }
836
+ /**
837
+ * The one capability the resolution oracle is given: does this path exist, what
838
+ * does it really point at, and, for a `package.json`, what does it say.
839
+ *
840
+ * `realPath` is what makes the answer honest through a text shim: the file that
841
+ * will really execute is the one whose package decides whether `.tsrx` is
842
+ * understood. Both replays below share this seam so `status` and the oracle can
843
+ * never be answering from two different views of the same tree.
844
+ */
845
+ async function editorResolutionStat(candidate) {
846
+ const real = await realPathOrNull(candidate);
847
+ if (!real) return null;
848
+ return {
849
+ realPath: real,
850
+ content: basename(candidate) === "package.json" ? await readFile(candidate, "utf8").catch(() => null) : null
851
+ };
852
+ }
853
+ /** The folders above this one that look like a workspace root, named with their evidence. */
854
+ function workspaceRootsNote(settingsRoot, workspaceRoots) {
855
+ const listed = workspaceRoots.map((candidate) => `${candidate.path} (${candidate.evidence})`).join(", ");
856
+ return workspaceRoots.length === 1 ? `VS Code reads .vscode/settings.json only from the folder you open as the workspace root, never from a subfolder of it. This folder above ${settingsRoot} looks like a workspace root: ${listed}. Open that one instead and this key is never read.` : `VS Code reads .vscode/settings.json only from the folder you open as the workspace root, never from a subfolder of it. These folders above ${settingsRoot} look like a workspace root: ${listed}. Open any of them instead and this key is never read.`;
857
+ }
858
+ /**
859
+ * Deliberate workspace markers earn the full inert-plus-remedies treatment: a
860
+ * declared monorepo root or a repository root is a folder people open by
861
+ * default. The weak tier - a lockfile or node_modules next to a plain manifest,
862
+ * the shape every scaffold-inside-a-demo-folder walkthrough manufactures - is a
863
+ * folder someone MIGHT open, and a happy-path setup drowning that maybe in a
864
+ * warning wall teaches readers to skip the report entirely. Weak-only ancestors
865
+ * keep the slot active and get one line naming the folder and the one command.
866
+ */
867
+ function strongWorkspaceRoots(workspaceRoots) {
868
+ return workspaceRoots.filter((candidate) => candidate.evidence.endsWith(CODE_WORKSPACE_SUFFIX) || WORKSPACE_ROOT_EVIDENCE.indexOf(candidate.evidence) <= WORKSPACE_ROOT_EVIDENCE.indexOf(".git"));
869
+ }
870
+ /**
871
+ * The other settings files a window might actually read, each with the value
872
+ * that is correct for its own folder. VS Code reads settings only from the
873
+ * opened folder, so full coverage means one correct key per folder someone
874
+ * plausibly opens: the project root, and every WEAK candidate above it - an
875
+ * installed folder that declares no workspace and carries no VCS, the shape a
876
+ * scaffold-inside-a-demo-folder walkthrough manufactures. `setup` writes these
877
+ * automatically. Strong roots (a declared monorepo, a repository root, a
878
+ * `.code-workspace`) stay behind the explicit `--workspace-root` flag: writing
879
+ * into a checked-in tree's settings uninvited is the footgun the flag exists
880
+ * for. An explicit flag also disables the automatic placements, because naming
881
+ * a folder is choosing it.
882
+ */
883
+ async function editorAncestorPlacements(projectRoot, providerRoot, settingsRoot, workspaceRoots, explicitWorkspaceRoot) {
884
+ if (explicitWorkspaceRoot !== void 0 && explicitWorkspaceRoot !== null) return [];
885
+ const strong = new Set(strongWorkspaceRoots(workspaceRoots).map((c) => c.path));
886
+ const roots = [projectRoot, ...workspaceRoots.map((c) => c.path)].filter((root) => root !== settingsRoot && !strong.has(root));
887
+ const placements = [];
888
+ for (const root of roots) {
889
+ const path = join(root, EDITOR_SLOT.directory, EDITOR_SLOT.file);
890
+ const settings = await readJson(path).catch(() => null);
891
+ placements.push({
892
+ root,
893
+ path,
894
+ value: await editorSettingValue(root, projectRoot, providerRoot),
895
+ current: typeof settings?.[EDITOR_SLOT.key] === "string" ? settings[EDITOR_SLOT.key] : null
896
+ });
897
+ }
898
+ return placements;
899
+ }
900
+ /**
901
+ * The two remedies, in the order to try them, worded identically for a key that
902
+ * was written into a folder nobody opens and for a lookup that only wins in a
903
+ * folder nobody opens. The reader's move is the same in both cases.
904
+ */
905
+ function editorRemediesNote(projectRoot) {
906
+ return `Two remedies, in order: open ${projectRoot} as the folder in your editor, or - from ${projectRoot} - run npx ${PROVIDER} setup --workspace-root <folder> to write the key into that folder's .vscode/settings.json instead. setup never writes above your project root without that flag, because a key written for a folder you did not open disables the extension's own lookup and leaves the linter dead.`;
907
+ }
908
+ /**
909
+ * Would the editor actually run this value?
910
+ *
911
+ * Written is not wired. This replays the official extension's own handling of
912
+ * `oxc.path.oxlint` through the resolution oracle and reports every reason the
913
+ * value would not reach a linter, so `status` can refuse to call a written key
914
+ * active. All four refusals are collected rather than short-circuited: a value
915
+ * can be both in a file the editor never reads and unspawnable once it does.
916
+ */
917
+ async function judgeEditorReach({ settingsRoot, projectRoot, value, workspaceRoots, ancestorPlacements = [], platform }) {
918
+ const notes = [];
919
+ const rejection = value === null ? null : rejectConfiguredValue(value);
920
+ if (rejection === "configured-rejected-traversal") notes.push(`The official OXC extension refuses any "${EDITOR_SLOT.key}" containing ".." or ".\\" before it looks at the filesystem, so "${value}" never reaches a linter. There is no relative escape hatch: the value has to name a path inside the folder you open.`);
921
+ else if (rejection === "configured-rejected-metacharacter") notes.push(`The official OXC extension refuses any "${EDITOR_SLOT.key}" containing $ & ; | \` > < ! % ^, so "${value}" never reaches a linter. Point the key at a path with none of those characters in it.`);
922
+ const resolution = value === null ? null : await resolveEditorLinter({
923
+ name: "oxlint",
924
+ configured: value,
925
+ workspaceFolders: [settingsRoot],
926
+ trusted: true,
927
+ stat: editorResolutionStat
928
+ });
929
+ const spawnable = resolution === null || resolution.reason !== "resolved" ? false : isSpawnable(resolution.path, resolution.loader, platform);
930
+ if (resolution && !rejection && resolution.reason !== "resolved") notes.push(`${resolution.attempted ?? value} does not exist, so the extension would find no linter at all. A configured "${EDITOR_SLOT.key}" replaces the extension's own node_modules lookup instead of adding to it, with no fallback, so a value that does not resolve is worse than no value. Run ${PROVIDER} setup to refresh it.`);
931
+ if (resolution?.reason === "resolved" && !spawnable) notes.push(`On Windows the extension spawns a value like this through cmd.exe, which can only run .exe, .com, .bat and .cmd. "${value}" has no file extension, so the spawn fails and the editor stays silent with no error anywhere. Add "oxc.useExecPath": true to the same settings file to have it launched with Node instead.`);
932
+ const strong = strongWorkspaceRoots(workspaceRoots);
933
+ if (strong.length > 0) {
934
+ notes.push(workspaceRootsNote(settingsRoot, strong));
935
+ notes.push(editorRemediesNote(projectRoot));
936
+ }
937
+ const covered = ancestorPlacements.filter((p) => p.current === p.value);
938
+ if (covered.length > 0) notes.push(`Also covered: ${covered.map((p) => p.path).join(", ")}. A window opened at ${covered.length === 1 ? "that folder" : "any of those folders"} reads its own copy of the key.`);
939
+ if (settingsRoot !== projectRoot) notes.push(`"${value}" is relative to ${settingsRoot}. A multi-root window resolves a relative "${EDITOR_SLOT.key}" against its FIRST folder, not against the folder holding the settings file, so keep ${settingsRoot} first in the window or the editor looks for the linter in the wrong tree.`);
940
+ return {
941
+ state: Boolean(rejection) || resolution !== null && !spawnable ? "unresolvable" : strong.length > 0 ? "inert" : "ok",
942
+ value,
943
+ windowRoot: settingsRoot,
944
+ platform,
945
+ rejection,
946
+ resolution: resolution ? {
947
+ source: resolution.source,
948
+ path: resolution.path,
949
+ reason: resolution.reason,
950
+ loader: resolution.loader,
951
+ spawnable,
952
+ tsrxAware: resolution.tsrxAware
953
+ } : null,
954
+ notes
955
+ };
956
+ }
957
+ /**
958
+ * Would the extension's *own* lookup reach this package from every folder the
959
+ * consumer might plausibly open?
960
+ *
961
+ * `node_modules/.bin/oxlint` being ours is the reason nothing is written, so it
962
+ * had better be the reason that survives being asked from the folder the editor
963
+ * is actually opened at. It often does not. `.bin` is searched per workspace
964
+ * folder, first hit wins, so a monorepo whose root carries a competing
965
+ * `.bin/oxlint` resolves *there* and never looks at the package below it. The
966
+ * report used to say "the editor needs no setting" for exactly that tree: a
967
+ * green line over a silent editor.
968
+ *
969
+ * So auto-detection is replayed with `configured: null`, once from the folder
970
+ * holding the settings file and once from every candidate workspace root, and
971
+ * `unnecessary` is only kept when all of them land in this package.
972
+ *
973
+ * Two deliberate conservatisms, because a false alarm here would be worse than
974
+ * the gap it closes:
975
+ *
976
+ * - Only a candidate that *resolves* to a foreign binary demotes. A candidate
977
+ * that resolves to nothing at all does not, because this replay is given a
978
+ * subset of the real chain (no `require.resolve`, no global roots, no `PATH`),
979
+ * so "found nothing" is this process's ignorance rather than a measurement.
980
+ * - The `package.json` glob step is given the project's own directory only. The
981
+ * extension globs the whole workspace and the order of those hits is not
982
+ * knowable from here, so a sibling package that might shadow this one is left
983
+ * unclaimed rather than guessed at.
984
+ */
985
+ async function judgeAutoDetection({ settingsRoot, projectRoot, modules, providerRoot, shim, workspaceRoots, platform }) {
986
+ const providerReal = await realPathOrNull(providerRoot) ?? providerRoot;
987
+ const facadeReal = await realPathOrNull(join(modules, "oxlint"));
988
+ const folders = [{
989
+ path: settingsRoot,
990
+ evidence: null
991
+ }, ...workspaceRoots];
992
+ const candidates = [];
993
+ for (const folder of folders) {
994
+ const resolution = await resolveEditorLinter({
995
+ name: "oxlint",
996
+ configured: null,
997
+ workspaceFolders: [folder.path],
998
+ packageJsonDirectories: [projectRoot],
999
+ trusted: true,
1000
+ stat: editorResolutionStat
1001
+ });
1002
+ const reaches = resolution.reason === "resolved" && (resolution.tsrxAware || await leadsIntoProvider(resolution.realPath ?? resolution.path, providerReal, facadeReal));
1003
+ candidates.push({
1004
+ root: folder.path,
1005
+ evidence: folder.evidence,
1006
+ path: resolution.path,
1007
+ reason: resolution.reason,
1008
+ source: resolution.source,
1009
+ tsrxAware: resolution.tsrxAware,
1010
+ reaches
1011
+ });
1012
+ }
1013
+ const diverging = candidates.filter((candidate) => candidate.reason === "resolved" && !candidate.reaches);
1014
+ const notes = [];
1015
+ if (diverging.length > 0) {
1016
+ const listed = diverging.map((candidate) => `${candidate.root}${candidate.evidence ? ` (${candidate.evidence})` : ""} finds ${candidate.path}`).join("; ");
1017
+ notes.push(`${shim.path} does resolve into this package, but that only decides what a window opened at ${projectRoot} finds. VS Code reads .vscode/settings.json only from the folder you open as the workspace root, and the extension searches each opened folder's own node_modules/.bin first, so a folder above this one finds a different linter: ${listed}. That binary does not understand .tsrx, so opening it gives no .tsrx diagnostics and nothing anywhere says why.`);
1018
+ notes.push(editorRemediesNote(projectRoot));
1019
+ }
1020
+ return {
1021
+ state: diverging.length > 0 ? "inert" : "ok",
1022
+ value: null,
1023
+ windowRoot: settingsRoot,
1024
+ platform,
1025
+ rejection: null,
1026
+ resolution: null,
1027
+ autoDetection: candidates,
1028
+ notes
1029
+ };
1030
+ }
1031
+ async function inspectEditorSlot(projectRoot, providerRoot, modules, options = {}) {
1032
+ const receipt = await readEditorReceipt(modules);
1033
+ const settingsRoot = await editorSettingsRoot(projectRoot, receipt, options.workspaceRoot);
1034
+ const platform = options.platform ?? process.platform;
1035
+ const path = join(settingsRoot, EDITOR_SLOT.directory, EDITOR_SLOT.file);
1036
+ const shim = await inspectLinterShim(modules, providerRoot);
1037
+ const value = await editorSettingValue(settingsRoot, projectRoot, providerRoot);
1038
+ const workspaceRoots = await candidateWorkspaceRoots(settingsRoot);
1039
+ const ancestorPlacements = await editorAncestorPlacements(projectRoot, providerRoot, settingsRoot, workspaceRoots, options.workspaceRoot);
1040
+ const base = {
1041
+ name: EDITOR_SLOT.name,
1042
+ capability: EDITOR_SLOT.capability,
1043
+ key: EDITOR_SLOT.key,
1044
+ path,
1045
+ settingsRoot,
1046
+ value,
1047
+ linterShim: shim,
1048
+ workspaceRoots,
1049
+ ancestorPlacements
1050
+ };
1051
+ const judge = async (state, currentValue) => ({
1052
+ ...base,
1053
+ state,
1054
+ currentValue,
1055
+ reach: await judgeEditorReach({
1056
+ settingsRoot,
1057
+ projectRoot,
1058
+ value: ["active", "collision"].includes(state) && typeof currentValue === "string" ? currentValue : value,
1059
+ workspaceRoots,
1060
+ ancestorPlacements,
1061
+ platform
1062
+ })
1063
+ });
1064
+ const reported = async (state, currentValue) => {
1065
+ const slot = await judge(state, currentValue);
1066
+ return {
1067
+ ...slot,
1068
+ state: state === "active" && slot.reach.state !== "ok" ? slot.reach.state : state,
1069
+ notes: slot.reach.notes
1070
+ };
1071
+ };
1072
+ const unwritten = async () => {
1073
+ if (shim.owner !== PACKAGE_NAME) return reported("missing", null);
1074
+ const reach = await judgeAutoDetection({
1075
+ settingsRoot,
1076
+ projectRoot,
1077
+ modules,
1078
+ providerRoot,
1079
+ shim,
1080
+ workspaceRoots,
1081
+ platform
1082
+ });
1083
+ return {
1084
+ ...base,
1085
+ state: reach.state === "ok" ? "unnecessary" : "inert",
1086
+ currentValue: null,
1087
+ reach,
1088
+ notes: reach.notes
1089
+ };
1090
+ };
1091
+ if (!await exists(path)) return unwritten();
1092
+ const text = await readFile(path, "utf8").catch(() => null);
1093
+ if (text === null) return reported("unreadable", null);
1094
+ const structure = readTopLevelObject(text);
1095
+ if (!structure) return reported("unreadable", null);
1096
+ const entry = structure.entries.find((candidate) => candidate.key === EDITOR_SLOT.key);
1097
+ if (!entry) return unwritten();
1098
+ const current = stringEntryValue(entry);
1099
+ if (typeof current === "string") {
1100
+ const resolved = await realPathOrNull(isAbsolute(current) ? current : join(settingsRoot, current));
1101
+ const providerReal = await realPathOrNull(providerRoot) ?? providerRoot;
1102
+ if (resolved && within(providerReal, resolved)) return reported("active", current);
1103
+ if (receipt && receipt.value === current) return reported("stale", current);
1104
+ }
1105
+ return reported("collision", current);
1106
+ }
1107
+ /**
1108
+ * Merge the key into one settings file, replacing a stale copy when asked, and
1109
+ * report whether the file or its directory had to be created so `remove` can
1110
+ * take back exactly what `setup` made.
1111
+ */
1112
+ async function mergeEditorKey(path, value, { replaceStale = false } = {}) {
1113
+ const directory = dirname(path);
1114
+ const createdDirectory = !await exists(directory);
1115
+ if (createdDirectory) await mkdir(directory, { recursive: true });
1116
+ const createdFile = !await exists(path);
1117
+ const previous = createdFile ? "{}\n" : await readFile(path, "utf8");
1118
+ const structure = readTopLevelObject(previous);
1119
+ if (!structure) throw new Error(`refusing to edit ${path}: its top-level JSON object could not be located`);
1120
+ const cleaned = replaceStale ? removeTopLevelEntry(previous, structure, EDITOR_SLOT.key) : previous;
1121
+ const target = replaceStale ? readTopLevelObject(cleaned) : structure;
1122
+ if (!target) throw new Error(`refusing to edit ${path}: rewriting it would not round-trip`);
1123
+ await writeFile(path, insertTopLevelEntry(cleaned, target, EDITOR_SLOT.key, value));
1124
+ return {
1125
+ createdFile,
1126
+ createdDirectory
1127
+ };
1128
+ }
1129
+ async function writeEditorSlot(projectRoot, modules, slot) {
1130
+ const existing = await readEditorReceipt(modules);
1131
+ let created = {
1132
+ createdFile: false,
1133
+ createdDirectory: false
1134
+ };
1135
+ if (slot.currentValue !== slot.value) created = await mergeEditorKey(slot.path, slot.value, { replaceStale: slot.state === "stale" });
1136
+ const placements = [];
1137
+ const existingPlacements = new Map((existing?.placements ?? []).map((placement) => [placement.settingsPath, placement]));
1138
+ for (const placement of slot.ancestorPlacements ?? []) {
1139
+ const settingsPath = toPosix(relative(projectRoot, placement.path));
1140
+ const previous = existingPlacements.get(settingsPath);
1141
+ let placementCreated = {
1142
+ createdFile: previous?.createdFile === true,
1143
+ createdDirectory: previous?.createdDirectory === true
1144
+ };
1145
+ if (placement.current !== placement.value) {
1146
+ const written = await mergeEditorKey(placement.path, placement.value, { replaceStale: placement.current !== null });
1147
+ placementCreated = {
1148
+ createdFile: placementCreated.createdFile || written.createdFile,
1149
+ createdDirectory: placementCreated.createdDirectory || written.createdDirectory
1150
+ };
1151
+ }
1152
+ placements.push({
1153
+ settingsPath,
1154
+ value: placement.value,
1155
+ ...placementCreated
1156
+ });
1157
+ }
1158
+ await mkdir(join(modules, EDITOR_RECEIPT[0]), { recursive: true });
1159
+ await writeFile(join(modules, ...EDITOR_RECEIPT), `${JSON.stringify({
1160
+ schemaVersion: COMPATIBILITY_SCHEMA,
1161
+ provider: PROVIDER,
1162
+ key: EDITOR_SLOT.key,
1163
+ value: slot.value,
1164
+ settingsPath: toPosix(relative(projectRoot, slot.path)),
1165
+ createdFile: existing?.createdFile === true ? true : created.createdFile,
1166
+ createdDirectory: existing?.createdDirectory === true ? true : created.createdDirectory,
1167
+ placements
1168
+ }, null, 2)}\n`);
1169
+ }
1170
+ async function removeEditorKeyFrom(path, { createdFile = false, createdDirectory = false } = {}) {
1171
+ const text = await readFile(path, "utf8").catch(() => null);
1172
+ if (text === null) return;
1173
+ const structure = readTopLevelObject(text);
1174
+ if (!structure) throw new Error(`refusing to edit ${path}: its top-level JSON object could not be located`);
1175
+ const next = removeTopLevelEntry(text, structure, EDITOR_SLOT.key);
1176
+ const remaining = readTopLevelObject(next);
1177
+ if (remaining !== null && remaining.entries.length === 0 && next.slice(remaining.openEnd, remaining.closeStart).trim().length === 0 && createdFile === true) {
1178
+ await rm(path, { force: true });
1179
+ if (createdDirectory === true) {
1180
+ const directory = dirname(path);
1181
+ if ((await readdir(directory).catch(() => ["keep"])).length === 0) await rmdir(directory).catch(() => {});
1182
+ }
1183
+ } else await writeFile(path, next);
1184
+ }
1185
+ async function revertEditorSlot(modules, slot) {
1186
+ const receipt = await readEditorReceipt(modules);
1187
+ await removeEditorKeyFrom(slot.path, {
1188
+ createdFile: receipt?.createdFile === true,
1189
+ createdDirectory: receipt?.createdDirectory === true
1190
+ });
1191
+ const receiptPlacements = new Map((receipt?.placements ?? []).map((placement) => [placement.settingsPath, placement]));
1192
+ const seen = /* @__PURE__ */ new Set([slot.path]);
1193
+ for (const placement of receipt?.placements ?? []) {
1194
+ const path = resolve(join(modules, ".."), placement.settingsPath);
1195
+ if (seen.has(path)) continue;
1196
+ seen.add(path);
1197
+ await removeEditorKeyFrom(path, placement);
1198
+ }
1199
+ for (const placement of slot.ancestorPlacements ?? []) {
1200
+ if (seen.has(placement.path) || placement.current !== placement.value) continue;
1201
+ seen.add(placement.path);
1202
+ await removeEditorKeyFrom(placement.path, receiptPlacements.get(placement.path) ?? {});
1203
+ }
1204
+ await rm(join(modules, ...EDITOR_RECEIPT), { force: true });
1205
+ }
1206
+ async function resolveDependencyManifest(fromRequire, modules, name) {
1207
+ try {
1208
+ return await readJson(fromRequire.resolve(`${name}/package.json`));
1209
+ } catch {}
1210
+ const direct = join(modules, ...name.split("/"), "package.json");
1211
+ return await exists(direct) ? readJson(direct).catch(() => null) : null;
1212
+ }
1213
+ async function nearestTsconfig(projectRoot) {
1214
+ let directory = projectRoot;
1215
+ for (;;) {
1216
+ const candidate = join(directory, "tsconfig.json");
1217
+ if (await exists(candidate)) return candidate;
1218
+ const parent = dirname(directory);
1219
+ if (parent === directory) return null;
1220
+ directory = parent;
1221
+ }
1222
+ }
1223
+ function declaresTsrxPlugin(tsconfig) {
1224
+ const plugins = tsconfig?.compilerOptions?.plugins;
1225
+ return Array.isArray(plugins) && plugins.some((plugin) => plugin?.name === TSRX_TYPESCRIPT_PLUGIN);
1226
+ }
1227
+ /**
1228
+ * A solution-style tsconfig owns no files: it is `{ "files": [], "references": [...] }`,
1229
+ * the shape `vp create` scaffolds. Naming it in the advice below is worse than useless,
1230
+ * because a plugin declared there is inert. Measured on a stock Vite+ React app with
1231
+ * TypeScript 5.9.3: the plugin in the solution root answers `hover: any`, the same
1232
+ * plugin in the referenced project that owns `src` answers `hover: const legacy: number`.
1233
+ * So point at the project that actually contains the source.
1234
+ */
1235
+ function isSolutionStyle(tsconfig) {
1236
+ const files = tsconfig?.files;
1237
+ const references = tsconfig?.references;
1238
+ return Array.isArray(references) && references.length > 0 && Array.isArray(files) && files.length === 0 && tsconfig?.include === void 0;
1239
+ }
1240
+ /** The referenced project a solution-style root delegates source files to. */
1241
+ async function referencedSourceProject(tsconfigPath, tsconfig) {
1242
+ const references = Array.isArray(tsconfig?.references) ? tsconfig.references : [];
1243
+ const directory = dirname(tsconfigPath);
1244
+ for (const reference of references) {
1245
+ const target = typeof reference?.path === "string" ? reference.path : null;
1246
+ if (target === null) continue;
1247
+ const candidate = target.endsWith(".json") ? join(directory, target) : join(directory, target, "tsconfig.json");
1248
+ const text = await readFile(candidate, "utf8").catch(() => null);
1249
+ if (text === null) continue;
1250
+ const parsed = parseJsoncValue(text);
1251
+ const include = parsed?.include;
1252
+ if (Array.isArray(include) && include.some((entry) => typeof entry === "string" && entry.includes("src"))) return {
1253
+ path: candidate,
1254
+ declaresPlugin: declaresTsrxPlugin(parsed)
1255
+ };
1256
+ }
1257
+ return null;
1258
+ }
1259
+ const TSCONFIG_PLUGIN_LITERAL = `[{ "name": ${JSON.stringify(TSRX_TYPESCRIPT_PLUGIN)} }]`;
1260
+ async function writeTsconfigPlugin(tsconfigPath) {
1261
+ const text = await readFile(tsconfigPath, "utf8").catch(() => null);
1262
+ if (text === null) throw new Error(`refusing to edit ${tsconfigPath}: it could not be read`);
1263
+ const options = readCompilerOptions(text);
1264
+ if (!options) throw new Error(`refusing to edit ${tsconfigPath}: its "compilerOptions" object could not be located, so add "plugins": ${TSCONFIG_PLUGIN_LITERAL} yourself`);
1265
+ const existing = options.entries.find((entry) => entry.key === "plugins");
1266
+ if (existing) {
1267
+ if (text.slice(existing.valueStart, existing.valueEnd).includes(TSRX_TYPESCRIPT_PLUGIN)) return "present";
1268
+ throw new Error(`refusing to edit ${tsconfigPath}: "compilerOptions.plugins" already exists, so add { "name": ${JSON.stringify(TSRX_TYPESCRIPT_PLUGIN)} } to it yourself`);
1269
+ }
1270
+ await writeFile(tsconfigPath, insertObjectEntry(text, options, "plugins", TSCONFIG_PLUGIN_LITERAL));
1271
+ return "written";
1272
+ }
1273
+ function typescriptSupported(version) {
1274
+ const [major, minor] = String(version ?? "").split(".").map((part) => Number.parseInt(part, 10));
1275
+ if (!Number.isInteger(major) || !Number.isInteger(minor)) return false;
1276
+ return major === 5 && minor >= 9;
1277
+ }
1278
+ /**
1279
+ * Read-only. `.tsrx` as a language belongs to the TSRX toolchain, and `setup`
1280
+ * must not silently configure another project's tooling. It still has to say
1281
+ * what is missing, because a green bridge plus a dead editor otherwise gives a
1282
+ * user no way to tell which half is broken.
1283
+ */
1284
+ async function inspectLanguageSupport(projectRoot, modules) {
1285
+ const fromProject = createRequire(join(projectRoot, "package.json"));
1286
+ const pluginManifest = await resolveDependencyManifest(fromProject, modules, TSRX_TYPESCRIPT_PLUGIN);
1287
+ const binding = (await Promise.all(TSRX_FRAMEWORK_BINDINGS.map(async (name) => ({
1288
+ name,
1289
+ manifest: await resolveDependencyManifest(fromProject, modules, name)
1290
+ })))).find((candidate) => candidate.manifest !== null) ?? null;
1291
+ const tsconfigPath = await nearestTsconfig(projectRoot);
1292
+ const tsconfigText = tsconfigPath ? await readFile(tsconfigPath, "utf8").catch(() => null) : null;
1293
+ const tsconfig = tsconfigText === null ? null : parseJsoncValue(tsconfigText);
1294
+ const typescriptVersion = (await resolveDependencyManifest(fromProject, modules, "typescript"))?.version ?? null;
1295
+ const supported = typescriptSupported(typescriptVersion);
1296
+ const report = {
1297
+ typescriptPlugin: {
1298
+ package: TSRX_TYPESCRIPT_PLUGIN,
1299
+ present: pluginManifest !== null,
1300
+ version: pluginManifest?.version ?? null
1301
+ },
1302
+ frameworkBinding: {
1303
+ candidates: [...TSRX_FRAMEWORK_BINDINGS],
1304
+ present: binding !== null,
1305
+ name: binding?.name ?? null,
1306
+ version: binding?.manifest?.version ?? null
1307
+ },
1308
+ tsconfig: {
1309
+ path: tsconfigPath,
1310
+ readable: tsconfig !== null,
1311
+ declaresPlugin: tsconfig !== null && declaresTsrxPlugin(tsconfig),
1312
+ solutionStyle: tsconfig !== null && isSolutionStyle(tsconfig),
1313
+ delegate: null
1314
+ },
1315
+ typescript: {
1316
+ requirement: TYPESCRIPT_REQUIREMENT,
1317
+ present: typescriptVersion !== null,
1318
+ version: typescriptVersion,
1319
+ supported
1320
+ },
1321
+ notes: []
1322
+ };
1323
+ if (!report.typescriptPlugin.present) report.notes.push(`install ${TSRX_TYPESCRIPT_PLUGIN} yourself: it is what gives an editor TSRX language support, and oxc-tsrx never installs it`);
1324
+ if (!report.frameworkBinding.present) report.notes.push(`install a TSRX framework binding yourself (one of ${TSRX_FRAMEWORK_BINDINGS.join(", ")}); oxc-tsrx does not choose one for you`);
1325
+ if (!report.tsconfig.path) report.notes.push(`no tsconfig.json was found at or above ${projectRoot}; add one declaring "plugins": [{ "name": "${TSRX_TYPESCRIPT_PLUGIN}" }]`);
1326
+ else if (!report.tsconfig.readable) report.notes.push(`${report.tsconfig.path} could not be read as JSON, so its "plugins" list was not checked; oxc-tsrx never edits it`);
1327
+ else if (report.tsconfig.solutionStyle) {
1328
+ const delegate = await referencedSourceProject(report.tsconfig.path, tsconfig);
1329
+ report.tsconfig.delegate = delegate?.path ?? null;
1330
+ if (delegate === null) report.notes.push(`${report.tsconfig.path} is solution-style ("files": [], "references": [...]), so a plugin declared there is inert. Add "plugins": [{ "name": "${TSRX_TYPESCRIPT_PLUGIN}" }] to whichever referenced project includes your source; setup --write-tsconfig cannot pick one for you here`);
1331
+ else if (!delegate.declaresPlugin) report.notes.push(`add "plugins": [{ "name": "${TSRX_TYPESCRIPT_PLUGIN}" }] under compilerOptions in ${delegate.path}, or rerun setup with --write-tsconfig to have it added for you. Not ${report.tsconfig.path}: that one is solution-style ("files": [], "references": [...]) and a plugin declared there is inert`);
1332
+ } else if (!report.tsconfig.declaresPlugin) report.notes.push(`add "plugins": [{ "name": "${TSRX_TYPESCRIPT_PLUGIN}" }] under compilerOptions in ${report.tsconfig.path}, or rerun setup with --write-tsconfig to have it added for you`);
1333
+ if (!report.typescript.present) report.notes.push(`typescript is not resolvable from ${projectRoot}; ${TSRX_TYPESCRIPT_PLUGIN} needs typescript ${TYPESCRIPT_REQUIREMENT}`);
1334
+ else if (!supported) report.notes.push(`typescript ${typescriptVersion} is outside ${TSRX_TYPESCRIPT_PLUGIN}'s declared peer range (${TYPESCRIPT_REQUIREMENT}). It may still work; if the editor misbehaves, pinning typescript into that range is the first thing to try. oxc-tsrx never changes your typescript version`);
1335
+ report.ok = report.notes.length === 0;
1336
+ return report;
1337
+ }
1338
+ async function compatibilityStatus(options = {}) {
1339
+ const projectRoot = await findProjectRoot(options.projectRoot);
1340
+ const provider = await installedProvider(projectRoot);
1341
+ const modules = join(projectRoot, "node_modules");
1342
+ const slots = await Promise.all(SLOTS.map((slot) => inspectSlot(modules, slot, provider.manifest.version, provider.projectManifest)));
1343
+ return {
1344
+ projectRoot,
1345
+ packageManager: await detectPackageManager(projectRoot, options.userAgent),
1346
+ providerVersion: provider.manifest.version,
1347
+ selectedFrom: provider.selectedFrom,
1348
+ slots: slots.map(({ slot, destination, state, replacedPackage }) => ({
1349
+ name: slot.name,
1350
+ capability: slot.capability,
1351
+ path: destination,
1352
+ state,
1353
+ ...replacedPackage ? { replacedPackage } : {}
1354
+ })),
1355
+ editorSlot: await inspectEditorSlot(projectRoot, provider.root, modules, options),
1356
+ languageSupport: await inspectLanguageSupport(projectRoot, modules)
1357
+ };
1358
+ }
1359
+ async function setupCompatibility(options = {}) {
1360
+ const status = await compatibilityStatus(options);
1361
+ const collisions = status.slots.filter((slot) => slot.state === "collision");
1362
+ if (collisions.length > 0) throw new Error(`refusing to replace unowned package slot(s): ${collisions.map((slot) => slot.name).join(", ")}. Installing on top of the existing node_modules does not free the slot, so run rm -rf node_modules, install again, and run ${PROVIDER} setup again`);
1363
+ const modules = join(status.projectRoot, "node_modules");
1364
+ if (!await exists(modules)) throw new Error(`node_modules is missing under ${status.projectRoot}; install dependencies first`);
1365
+ let tsconfigWrite = null;
1366
+ if (options.writeTsconfig) {
1367
+ const { path: rootPath, solutionStyle, delegate } = status.languageSupport.tsconfig;
1368
+ if (!rootPath) throw new Error(`no tsconfig.json was found at or above ${status.projectRoot}, so there is nothing to write`);
1369
+ if (solutionStyle && !delegate) throw new Error(`refusing to edit ${rootPath}: it is solution-style ("files": [], "references": [...]), so a plugin declared there is inert, and no referenced project including your source was found`);
1370
+ const target = delegate ?? rootPath;
1371
+ tsconfigWrite = {
1372
+ path: target,
1373
+ state: options.dryRun ? "preview" : await writeTsconfigPlugin(target)
1374
+ };
1375
+ }
1376
+ const languageSupport = tsconfigWrite && tsconfigWrite.state === "written" ? await inspectLanguageSupport(status.projectRoot, modules) : status.languageSupport;
1377
+ const changed = status.slots.filter((slot) => [
1378
+ "missing",
1379
+ "replaceable",
1380
+ "stale"
1381
+ ].includes(slot.state)).map((slot) => slot.name);
1382
+ if (!options.dryRun) for (const slotStatus of status.slots) {
1383
+ if (!changed.includes(slotStatus.name)) continue;
1384
+ await replaceOwnedFacade({
1385
+ slot: SLOTS.find((candidate) => candidate.name === slotStatus.name),
1386
+ destination: slotStatus.path,
1387
+ state: slotStatus.state,
1388
+ replacedPackage: slotStatus.replacedPackage
1389
+ }, status.providerVersion, modules);
1390
+ }
1391
+ const placementsPending = (status.editorSlot.ancestorPlacements ?? []).some((placement) => placement.current !== placement.value);
1392
+ const editorWritten = ["missing", "stale"].includes(status.editorSlot.state) || placementsPending || status.editorSlot.state === "inert" && status.editorSlot.currentValue === null && options.workspaceRoot !== void 0 && options.workspaceRoot !== null;
1393
+ if (editorWritten) {
1394
+ if (!options.dryRun) await writeEditorSlot(status.projectRoot, modules, status.editorSlot);
1395
+ changed.push(status.editorSlot.name);
1396
+ }
1397
+ const editorSlot = editorWritten && !options.dryRun ? await inspectEditorSlot(status.projectRoot, (await installedProvider(status.projectRoot)).root, modules, options) : status.editorSlot;
1398
+ if (editorWritten && !options.dryRun) editorSlot.notes = [...editorSlot.notes ?? [], `The editor reads "${EDITOR_SLOT.key}" only when a window starts its lint server. Any window that is already open keeps its current server: reload it (Developer: Reload Window) for this change to take effect.`];
1399
+ return {
1400
+ ...status,
1401
+ action: options.dryRun ? "preview" : "setup",
1402
+ slots: status.slots.map((slot) => !options.dryRun && changed.includes(slot.name) ? {
1403
+ ...slot,
1404
+ state: "active"
1405
+ } : slot),
1406
+ editorSlot,
1407
+ languageSupport,
1408
+ ...tsconfigWrite ? { tsconfigWrite } : {},
1409
+ changed,
1410
+ unchanged: [...status.slots.filter((slot) => slot.state === "active").map((slot) => slot.name), ...editorWritten ? [] : [status.editorSlot.name]]
1411
+ };
1412
+ }
1413
+ async function removeCompatibility(options = {}) {
1414
+ const status = await compatibilityStatus(options);
1415
+ const removed = [];
1416
+ for (const slot of status.slots) {
1417
+ if (!["active", "stale"].includes(slot.state)) continue;
1418
+ removed.push(slot.name);
1419
+ if (!options.dryRun) if (slot.replacedPackage) {
1420
+ const candidate = SLOTS.find((entry) => entry.name === slot.name);
1421
+ const backup = backupPath(join(status.projectRoot, "node_modules"), candidate);
1422
+ if (!await exists(backup)) throw new Error(`cannot remove ${slot.name}: preserved ${slot.replacedPackage.name}@${slot.replacedPackage.version} is missing at ${backup}`);
1423
+ const temporary = `${slot.path}.oxc-tsrx-remove-${process.pid}`;
1424
+ await rm(temporary, {
1425
+ recursive: true,
1426
+ force: true
1427
+ });
1428
+ await rename(slot.path, temporary);
1429
+ try {
1430
+ await rename(backup, slot.path);
1431
+ } catch (error) {
1432
+ await rename(temporary, slot.path);
1433
+ throw error;
1434
+ }
1435
+ await rm(temporary, {
1436
+ recursive: true,
1437
+ force: true
1438
+ });
1439
+ } else await rm(slot.path, {
1440
+ recursive: true,
1441
+ force: true
1442
+ });
1443
+ }
1444
+ const editorRemoved = [
1445
+ "active",
1446
+ "stale",
1447
+ "inert",
1448
+ "unresolvable"
1449
+ ].includes(status.editorSlot.state) && status.editorSlot.currentValue !== null;
1450
+ if (editorRemoved) {
1451
+ if (!options.dryRun) await revertEditorSlot(join(status.projectRoot, "node_modules"), status.editorSlot);
1452
+ removed.push(status.editorSlot.name);
1453
+ }
1454
+ return {
1455
+ ...status,
1456
+ action: options.dryRun ? "preview-remove" : "remove",
1457
+ slots: status.slots.map((slot) => !options.dryRun && removed.includes(slot.name) ? {
1458
+ ...slot,
1459
+ state: slot.replacedPackage ? "replaceable" : "missing"
1460
+ } : slot),
1461
+ editorSlot: editorRemoved && !options.dryRun ? {
1462
+ ...status.editorSlot,
1463
+ state: "missing",
1464
+ currentValue: null
1465
+ } : status.editorSlot,
1466
+ removed
1467
+ };
1468
+ }
1469
+ const EDITOR_SLOT_EXPLANATION = Object.freeze({
1470
+ active: (slot, projectRoot) => `${toPosix(relative(projectRoot, slot.path))} carries "${slot.key}": "${slot.value}". This is the one file setup writes outside node_modules; it merges that single key and never edits package.json or tsconfig.json.`,
1471
+ stale: (slot, projectRoot) => `${toPosix(relative(projectRoot, slot.path))} carries a "${slot.key}" this package wrote that no longer resolves here; setup refreshes it to "${slot.value}".`,
1472
+ missing: (slot, projectRoot) => `${slot.linterShim.path} does not resolve into this package, so the official OXC extension would find no .tsrx support and say nothing about it. setup writes "${slot.key}": "${slot.value}" into ${toPosix(relative(projectRoot, slot.path))}, which is your tree, not node_modules.`,
1473
+ unnecessary: (slot) => `${slot.linterShim.path} already resolves into this package, so the editor needs no setting and none was written.`,
1474
+ collision: (slot, projectRoot) => `${toPosix(relative(projectRoot, slot.path))} already sets "${slot.key}" to "${slot.currentValue}". That is yours, so it was left alone; the editor will not use this package until it reads "${slot.value}".`,
1475
+ unreadable: (slot, projectRoot) => `${toPosix(relative(projectRoot, slot.path))} could not be read as a single top-level JSON object, so nothing was written. Set "${slot.key}": "${slot.value}" there yourself.`,
1476
+ inert: (slot, projectRoot) => slot.currentValue === null ? `${slot.linterShim.path} resolves into this package, so a window opened at ${projectRoot} needs no setting and none was written. A folder above it resolves elsewhere, so this is reported rather than called unnecessary.` : `${toPosix(relative(projectRoot, slot.path))} carries "${slot.key}": "${slot.currentValue}", and that value is right for this folder. Whether the editor ever reads it depends on which folder you open, so this is reported rather than claimed active.`,
1477
+ unresolvable: (slot, projectRoot) => `${toPosix(relative(projectRoot, slot.path))} carries "${slot.key}": "${slot.currentValue}", and the official OXC extension would not run it. A configured value replaces the extension's own lookup instead of adding to it, so this is worse than no key at all.`
1478
+ });
1479
+ /**
1480
+ * The width the report wraps to. A terminal reports its own; anything else,
1481
+ * including the pipe a transcript is captured through, gets a fixed 80 so the
1482
+ * recorded output is identical on every machine.
1483
+ */
1484
+ function reportWidth() {
1485
+ const columns = process.stdout?.columns;
1486
+ if (!Number.isInteger(columns) || columns <= 0) return 80;
1487
+ return Math.min(Math.max(columns, 60), 100);
1488
+ }
1489
+ /**
1490
+ * Colour is for a human at a terminal and nobody else. A pipe, a CI log, a
1491
+ * captured transcript, or `NO_COLOR` all get plain text, so the only consumer
1492
+ * that ever sees an escape sequence is the one that can render it.
1493
+ * `FORCE_COLOR` is honoured because that is how you ask for it through a pipe.
1494
+ */
1495
+ function reportColorEnabled() {
1496
+ if (process.env.NO_COLOR !== void 0 && process.env.NO_COLOR !== "") return false;
1497
+ if (process.env.FORCE_COLOR !== void 0 && process.env.FORCE_COLOR !== "0") return true;
1498
+ return process.stdout?.isTTY === true;
1499
+ }
1500
+ const REPORT_STYLES = {
1501
+ bold: "1",
1502
+ dim: "2",
1503
+ green: "32",
1504
+ yellow: "33",
1505
+ cyan: "36"
1506
+ };
1507
+ function paint(text, style, enabled) {
1508
+ if (!enabled || !REPORT_STYLES[style]) return text;
1509
+ return `[${REPORT_STYLES[style]}m${text}`;
1510
+ }
1511
+ /**
1512
+ * `missing` is the healthy answer outside Vite+, so no state here is coloured
1513
+ * as an error. Green marks a slot this package has taken over, dim marks one
1514
+ * that needs nothing, and yellow marks the states that are asking the reader
1515
+ * to look at something.
1516
+ */
1517
+ const SLOT_STATE_STYLE = {
1518
+ active: "green",
1519
+ unnecessary: "dim",
1520
+ missing: "yellow",
1521
+ collision: "yellow",
1522
+ unreadable: "yellow",
1523
+ inert: "yellow",
1524
+ unresolvable: "yellow",
1525
+ removed: "dim"
1526
+ };
1527
+ /**
1528
+ * Wraps at spaces only. A path, a version range, or a `"plugins": [{ ... }]`
1529
+ * fragment must survive intact, because the reader's next move is to copy it
1530
+ * out of the terminal.
1531
+ */
1532
+ function wrapReportText(text, firstPrefix, restPrefix, width) {
1533
+ const limit = Math.max(width - restPrefix.length, 24);
1534
+ const lines = [];
1535
+ let current = "";
1536
+ for (const word of text.split(" ")) if (current === "") current = word;
1537
+ else if (`${current} ${word}`.length <= limit) current = `${current} ${word}`;
1538
+ else {
1539
+ lines.push(current);
1540
+ current = word;
1541
+ }
1542
+ if (current !== "") lines.push(current);
1543
+ return lines.map((line, index) => `${index === 0 ? firstPrefix : restPrefix}${line}`);
1544
+ }
1545
+ /**
1546
+ * One text report for `status`, `setup`, and `remove`, so all three describe the
1547
+ * same four slots and the same unowned editor prerequisites in the same words.
1548
+ *
1549
+ * The states are padded into a column and every prose line is wrapped: this
1550
+ * report is read in a terminal after an install has already scrolled past, and
1551
+ * an unwrapped wall of it hid a single `missing` among three `active`.
1552
+ */
1553
+ function formatCompatibilityReport(result) {
1554
+ const width = reportWidth();
1555
+ const color = reportColorEnabled();
1556
+ const lines = [];
1557
+ const changes = result.changed ?? result.removed ?? null;
1558
+ if (changes) {
1559
+ const verb = result.action === "remove" ? "removed" : result.action;
1560
+ const noun = changes.length === 1 ? "slot" : "slots";
1561
+ lines.push(paint(`${verb} ${changes.length} compatibility ${noun} for ${PROVIDER} ${result.providerVersion} (${result.packageManager})`, "bold", color));
1562
+ } else lines.push(paint(`${PROVIDER} ${result.providerVersion} compatibility (${result.packageManager})`, "bold", color));
1563
+ const editor = result.editorSlot;
1564
+ const rows = result.slots.map((slot) => [
1565
+ slot.name,
1566
+ slot.state,
1567
+ slot.state
1568
+ ]);
1569
+ if (editor) rows.push([
1570
+ editor.name,
1571
+ `${editor.state} (editor)`,
1572
+ editor.state
1573
+ ]);
1574
+ if (result.tsconfigWrite) {
1575
+ const { path, state } = result.tsconfigWrite;
1576
+ rows.push([
1577
+ basename(path),
1578
+ `${state} (tsconfig)`,
1579
+ state === "preview" ? "stale" : "active"
1580
+ ]);
1581
+ }
1582
+ const nameWidth = Math.max(...rows.map(([name]) => name.length));
1583
+ lines.push("");
1584
+ for (const [name, label, state] of rows) {
1585
+ const gutter = ` ${`${name}:`.padEnd(nameWidth + 1)} `;
1586
+ lines.push(`${gutter}${paint(label, SLOT_STATE_STYLE[state] ?? "cyan", color)}`);
1587
+ }
1588
+ if (editor) {
1589
+ const explain = EDITOR_SLOT_EXPLANATION[editor.state];
1590
+ if (explain) {
1591
+ lines.push("");
1592
+ for (const line of wrapReportText(explain(editor, result.projectRoot), " ", " ", width)) lines.push(paint(line, "dim", color));
1593
+ }
1594
+ for (const note of editor.notes ?? []) {
1595
+ lines.push("");
1596
+ const [first, ...rest] = wrapReportText(note, "", " ", width);
1597
+ lines.push(` ${paint("!", "yellow", color)} ${first}`);
1598
+ lines.push(...rest);
1599
+ }
1600
+ }
1601
+ const support = result.languageSupport;
1602
+ if (support && !support.ok) {
1603
+ lines.push("");
1604
+ lines.push(...wrapReportText("TSRX language support in the editor belongs to the TSRX toolchain, not to this package. Nothing below was installed, changed, or configured:", "", "", width).map((line) => paint(line, "dim", color)));
1605
+ for (const note of support.notes) {
1606
+ lines.push("");
1607
+ const [first, ...rest] = wrapReportText(note, "", " ", width);
1608
+ lines.push(` ${paint("!", "yellow", color)} ${first}`);
1609
+ lines.push(...rest);
1610
+ }
1611
+ }
1612
+ return `${lines.join("\n")}\n`;
1613
+ }
1614
+ //#endregion
1615
+ export { compatibilityStatus, findProjectRoot, formatCompatibilityReport, removeCompatibility, setupCompatibility };