@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.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/THIRD_PARTY_NOTICES.md +49 -0
- package/bin/oxc-tsrx +2 -0
- package/bin/oxc-tsrx-fmt +2 -0
- package/bin/oxc-tsrx-lint +2 -0
- package/bin/oxc-tsrx-lsp +2 -0
- package/bin/oxfmt +2 -0
- package/bin/oxlint +2 -0
- package/dist/bin/oxc-tsrx-fmt.js +13 -0
- package/dist/bin/oxc-tsrx-lint.js +13 -0
- package/dist/bin/oxc-tsrx-lsp.js +13 -0
- package/dist/bin/oxc-tsrx.js +115 -0
- package/dist/bin/oxfmt.js +24 -0
- package/dist/bin/oxlint.js +33 -0
- package/dist/canonical-command.d.ts +50 -0
- package/dist/canonical-command.js +196 -0
- package/dist/compat.d.ts +149 -0
- package/dist/compat.js +1615 -0
- package/dist/editor-resolution.js +508 -0
- package/dist/format-cli.js +276 -0
- package/dist/format-invocation.js +97 -0
- package/dist/format.d.ts +1 -0
- package/dist/format.js +56 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +16 -0
- package/dist/lint-cli.js +487 -0
- package/dist/lint-invocation.js +192 -0
- package/dist/lint-js-plugins.js +819 -0
- package/dist/lint-plugins-dev.d.ts +1 -0
- package/dist/lint-plugins-dev.js +2 -0
- package/dist/lint-prestart.js +16 -0
- package/dist/lint.d.ts +1 -0
- package/dist/lint.js +2 -0
- package/dist/native-targets.js +76 -0
- package/dist/oxlint-lsp-multiplexer.js +622 -0
- package/dist/package-binary.js +29 -0
- package/dist/parser.d.ts +216 -0
- package/dist/parser.js +557 -0
- package/dist/process.js +88 -0
- package/dist/provider-resolve.d.ts +160 -0
- package/dist/provider-resolve.js +471 -0
- package/dist/providers-report.js +49 -0
- package/dist/runtime.js +323 -0
- package/dist/spawn-command.d.ts +20 -0
- package/dist/spawn-command.js +87 -0
- package/dist/tsrx-core-compat/facade.js +1184 -0
- package/dist/tsrx-core-compat/index.d.ts +6 -0
- package/dist/tsrx-core-compat/index.js +9 -0
- package/dist/tsrx-core-compat/style.js +525 -0
- package/dist/tsrx-core-compat/types/estree.d.ts +20 -0
- package/dist/tsrx-core-compat/types/index.d.ts +50 -0
- package/dist/tsrx-transfer.js +352 -0
- 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}[0m`;
|
|
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 };
|