ts-builds 4.0.2 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/dist/cli.js +435 -31
- package/eslint.config.fp.js +2 -0
- package/eslint.config.functype.js +3 -0
- package/package-scripts.json +3 -3
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -132,6 +132,25 @@ If your project uses ESLint plugins not bundled with ts-builds (e.g., `eslint-pl
|
|
|
132
132
|
|
|
133
133
|
This tells ts-builds to use your project's ESLint installation instead of the bundled version.
|
|
134
134
|
|
|
135
|
+
### Linting Tests
|
|
136
|
+
|
|
137
|
+
`ts-builds lint` covers `srcDir`. Set `lint.includeTests` to cover `testDir` as well:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"testDir": "./test",
|
|
142
|
+
"lint": {
|
|
143
|
+
"includeTests": true
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- It's off by default in 4.x, because turning it on can fail `validate` in a repo whose tests were never linted. 5.0 turns it on.
|
|
149
|
+
- A `testDir` that doesn't exist is skipped. One with nothing ESLint can lint, such as only JSON fixtures, stops `lint` with exit code 2, the same as an empty `srcDir`.
|
|
150
|
+
- `.ts-builds/lint-report.json` and `lint:summary` count both directories.
|
|
151
|
+
- The fp and functype configs turn off the rules that misread a test's shape, for test files only: `@typescript-eslint/require-await` and `@typescript-eslint/no-unnecessary-condition`, plus `functype/prefer-option`, `prefer-either` and `prefer-try` in the functype config. The rule lists come from functype's `test-overrides` and `tests` configs. The base config enables none of these, so it has no override.
|
|
152
|
+
- The fp and functype configs use type-aware rules, so every test file has to be in a tsconfig. Add `test/**/*` to `include`, or point `parserOptions.project` at a `tsconfig.eslint.json` that has it. On the bundled path, test files outside any tsconfig count as errors and print as one message naming this fix. With `lint.useProjectEslint`, your ESLint reports each one itself.
|
|
153
|
+
|
|
135
154
|
### For SPAs/React Apps (Vite)
|
|
136
155
|
|
|
137
156
|
Use Vite instead of tsdown for SPA builds:
|
|
@@ -235,6 +254,99 @@ Wire-up:
|
|
|
235
254
|
aggregate a different root. Finding zero sidecars exits nonzero — an empty sweep
|
|
236
255
|
must not silently pass CI.
|
|
237
256
|
|
|
257
|
+
The sidecar also has a `rules` field with per-rule counts: errors, warnings, findings
|
|
258
|
+
silenced by an `eslint-disable` comment.
|
|
259
|
+
|
|
260
|
+
### Lint telemetry (opt-in)
|
|
261
|
+
|
|
262
|
+
`ts-builds lint` can send each run's per-rule counts to an endpoint you run. That shows which
|
|
263
|
+
rules fire, which ones people silence, and how both change between releases. It's off unless you
|
|
264
|
+
turn it on. ts-builds reads these settings from the environment only, so `ts-builds.config.json`
|
|
265
|
+
has no switch for it. A committed script, workflow or config command that sets them still turns it
|
|
266
|
+
on for everyone who runs it, so set them only where you mean to, such as your own CI.
|
|
267
|
+
|
|
268
|
+
| Env var | Effect |
|
|
269
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
270
|
+
| `TS_BUILDS_TELEMETRY=1` | Turns it on. |
|
|
271
|
+
| `TS_BUILDS_TELEMETRY_URL` | Where to POST. Must be `https`, or `http` to localhost. With telemetry on and no URL, `lint` warns once and sends nothing. |
|
|
272
|
+
| `TS_BUILDS_TELEMETRY_DEBUG=1` | Prints the payload to stderr and sends nothing, whether or not telemetry is on. |
|
|
273
|
+
| `DO_NOT_TRACK=1` | Turns it off, whatever else is set. |
|
|
274
|
+
|
|
275
|
+
Each run sends one JSON object:
|
|
276
|
+
|
|
277
|
+
```json
|
|
278
|
+
{
|
|
279
|
+
"schema": 1,
|
|
280
|
+
"package": "@acme/api",
|
|
281
|
+
"project": "3f9c2a1b7d4e6f80",
|
|
282
|
+
"ci": true,
|
|
283
|
+
"agent": "claude-code",
|
|
284
|
+
"fix": false,
|
|
285
|
+
"durationMs": 1840,
|
|
286
|
+
"files": 212,
|
|
287
|
+
"fatal": 0,
|
|
288
|
+
"versions": {
|
|
289
|
+
"node": "24.20.0",
|
|
290
|
+
"ts-builds": "4.2.0",
|
|
291
|
+
"eslint": "10.14.0",
|
|
292
|
+
"typescript": "6.0.3",
|
|
293
|
+
"eslint-plugin-functype": "2.125.1"
|
|
294
|
+
},
|
|
295
|
+
"enabled": { "functype/no-let": "error", "functype/prefer-option": "warn" },
|
|
296
|
+
"rules": { "functype/prefer-option": { "errors": 0, "warnings": 3, "silenced": 2 } }
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
- **Never sent:** file paths, source code or message text.
|
|
301
|
+
- **Sent as-is:** the `package.json` name, left out when there isn't one, and rule ids as your config
|
|
302
|
+
names them. Plugin names and versions are sent only when they look like an npm package name and a
|
|
303
|
+
semver version.
|
|
304
|
+
- **`project`:** a salted hash of the git remote, so one repo's packages group together. It's a
|
|
305
|
+
stable pseudonym, not anonymity: anyone who knows the remote URL can compute it. It's left out
|
|
306
|
+
when there's no remote.
|
|
307
|
+
- **`agent`:** set when a coding agent runs the lint. Today that's Claude Code, from `CLAUDECODE=1`.
|
|
308
|
+
- **`versions`:** Node, ts-builds, ESLint, TypeScript, and each configured plugin. A plugin's
|
|
309
|
+
installed version wins over the `meta.version` it declares, which is hand-written and can go stale.
|
|
310
|
+
When the installed package can't be found, the `meta.version` is used.
|
|
311
|
+
- **`enabled`:** the plugin rules that are on, with their severity, read from the first linted file's
|
|
312
|
+
config. It tells a rule with no findings apart from a rule that's off. Core rules are left out.
|
|
313
|
+
|
|
314
|
+
It never changes a run's result. It sends after lint finishes and its output is printed, waits at
|
|
315
|
+
most 2 seconds, and ignores every error, so the exit code and output stay the same. A run that
|
|
316
|
+
fails before linting, such as one with a broken config, sends nothing.
|
|
317
|
+
|
|
318
|
+
It doesn't cover `lint.useProjectEslint`, which spawns your own ESLint so ts-builds never sees the
|
|
319
|
+
results. It also doesn't cover scripts that call `eslint` directly instead of `ts-builds lint`.
|
|
320
|
+
|
|
321
|
+
To turn it on in GitHub Actions, set both variables on the job:
|
|
322
|
+
|
|
323
|
+
```yaml
|
|
324
|
+
env:
|
|
325
|
+
TS_BUILDS_TELEMETRY: "1"
|
|
326
|
+
TS_BUILDS_TELEMETRY_URL: ${{ vars.TS_BUILDS_TELEMETRY_URL }}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Under Turbo's strict env mode, a task only sees the variables Turbo is told about, plus a built-in
|
|
330
|
+
set that includes `CI`. List these so `ts-builds lint` sees them. Adding `globalPassThroughEnv` also
|
|
331
|
+
turns on strict mode for every task, so check that your other tasks still get the variables they need:
|
|
332
|
+
|
|
333
|
+
```jsonc
|
|
334
|
+
// turbo.json
|
|
335
|
+
{
|
|
336
|
+
"globalPassThroughEnv": [
|
|
337
|
+
"TS_BUILDS_TELEMETRY",
|
|
338
|
+
"TS_BUILDS_TELEMETRY_URL",
|
|
339
|
+
"TS_BUILDS_TELEMETRY_DEBUG",
|
|
340
|
+
"DO_NOT_TRACK",
|
|
341
|
+
"GITHUB_ACTIONS",
|
|
342
|
+
"CLAUDECODE",
|
|
343
|
+
],
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Pass-through variables don't change Turbo's cache key. A cached lint task doesn't run, so it sends
|
|
348
|
+
nothing.
|
|
349
|
+
|
|
238
350
|
## Extendable Configs
|
|
239
351
|
|
|
240
352
|
ts-builds exports base configurations you can extend:
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
-
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { $, Do, List, Map, Option, Set, Try } from "functype";
|
|
7
7
|
import { Fs, Process } from "functype-os";
|
|
8
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
9
10
|
import { createInterface } from "node:readline";
|
|
10
11
|
import { gzipSync } from "node:zlib";
|
|
11
12
|
|
|
@@ -57,7 +58,10 @@ function loadConfig() {
|
|
|
57
58
|
srcDir: userConfig.srcDir ?? "./src",
|
|
58
59
|
testDir: userConfig.testDir ?? "./test",
|
|
59
60
|
buildMode: userConfig.buildMode ?? "tsdown",
|
|
60
|
-
lint: {
|
|
61
|
+
lint: {
|
|
62
|
+
useProjectEslint: userConfig.lint?.useProjectEslint ?? false,
|
|
63
|
+
includeTests: userConfig.lint?.includeTests ?? false
|
|
64
|
+
},
|
|
61
65
|
size: userConfig.size ?? {},
|
|
62
66
|
changelog: userConfig.changelog ?? {},
|
|
63
67
|
commands,
|
|
@@ -65,6 +69,270 @@ function loadConfig() {
|
|
|
65
69
|
};
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
// src/cli/telemetry.ts
|
|
73
|
+
/**
|
|
74
|
+
* Opt-in lint telemetry: per-rule counts from a `ts-builds lint` run, sent to an endpoint you
|
|
75
|
+
* choose so you can see which rules fire, which get silenced, and whether that changes over time.
|
|
76
|
+
*
|
|
77
|
+
* Off unless `TS_BUILDS_TELEMETRY=1` is set, and `DO_NOT_TRACK=1` always turns it off. ts-builds
|
|
78
|
+
* reads these from the environment only. A committed script, workflow or config command that sets
|
|
79
|
+
* them still turns it on for everyone who runs it. It never sends file paths, source or messages,
|
|
80
|
+
* the send waits at most `TELEMETRY_TIMEOUT_MS`, and it never changes the run's exit code.
|
|
81
|
+
*/
|
|
82
|
+
/** Bump when the payload's shape changes incompatibly, so the receiver can tell versions apart. */
|
|
83
|
+
const TELEMETRY_SCHEMA_VERSION = 1;
|
|
84
|
+
/** The most a send can add to a lint run. A send that takes longer is dropped. */
|
|
85
|
+
const TELEMETRY_TIMEOUT_MS = 2e3;
|
|
86
|
+
/** A fixed salt, so the repo id is a stable pseudonym. Anyone who knows the remote URL can recompute it. */
|
|
87
|
+
const PROJECT_SALT = "ts-builds-telemetry-v1";
|
|
88
|
+
const LOG_PREFIX = "[ts-builds telemetry]";
|
|
89
|
+
const NO_COUNTS = {
|
|
90
|
+
errors: 0,
|
|
91
|
+
warnings: 0,
|
|
92
|
+
silenced: 0
|
|
93
|
+
};
|
|
94
|
+
const isOn = (value) => value !== void 0 && /^(1|true|yes)$/i.test(value.trim());
|
|
95
|
+
/** One hit per message that has a rule id. Messages without one are fatal, and counted apart. */
|
|
96
|
+
function hits(result) {
|
|
97
|
+
const reported = result.messages.flatMap((m) => m.ruleId === null ? [] : [{
|
|
98
|
+
ruleId: m.ruleId,
|
|
99
|
+
field: m.severity === 2 ? "errors" : "warnings"
|
|
100
|
+
}]);
|
|
101
|
+
const suppressed = result.suppressedMessages.flatMap((m) => m.ruleId === null ? [] : [{
|
|
102
|
+
ruleId: m.ruleId,
|
|
103
|
+
field: "silenced"
|
|
104
|
+
}]);
|
|
105
|
+
return [...reported, ...suppressed];
|
|
106
|
+
}
|
|
107
|
+
/** Per-rule counts for a lint run. Pure: the same results always give the same, key-sorted output. */
|
|
108
|
+
function ruleStats(results) {
|
|
109
|
+
const unsorted = results.flatMap(hits).reduce((acc, h) => {
|
|
110
|
+
const current = acc[h.ruleId] ?? NO_COUNTS;
|
|
111
|
+
return {
|
|
112
|
+
...acc,
|
|
113
|
+
[h.ruleId]: {
|
|
114
|
+
...current,
|
|
115
|
+
[h.field]: current[h.field] + 1
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}, {});
|
|
119
|
+
return {
|
|
120
|
+
rules: Object.fromEntries(Object.keys(unsorted).toSorted().map((id) => [id, unsorted[id] ?? NO_COUNTS])),
|
|
121
|
+
fatal: results.reduce((n, r) => n + r.messages.filter((m) => m.ruleId === null).length, 0)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const LOCAL_HOSTS = [
|
|
125
|
+
"localhost",
|
|
126
|
+
"127.0.0.1",
|
|
127
|
+
"[::1]"
|
|
128
|
+
];
|
|
129
|
+
/** An https URL, or http only to this machine (for tests and a local receiver). */
|
|
130
|
+
function isAllowedEndpoint(endpoint) {
|
|
131
|
+
return Try(() => new URL(endpoint)).map((url) => url.protocol === "https:" || url.protocol === "http:" && LOCAL_HOSTS.includes(url.hostname)).orElse(false);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* What to do with this run's telemetry, from the environment alone. `DO_NOT_TRACK` wins over
|
|
135
|
+
* everything. `TS_BUILDS_TELEMETRY_DEBUG` prints the payload and sends nothing, so you can see
|
|
136
|
+
* exactly what would go out before turning it on.
|
|
137
|
+
*/
|
|
138
|
+
function telemetryMode(env) {
|
|
139
|
+
if (isOn(env.DO_NOT_TRACK)) return { kind: "off" };
|
|
140
|
+
if (isOn(env.TS_BUILDS_TELEMETRY_DEBUG)) return { kind: "debug" };
|
|
141
|
+
if (!isOn(env.TS_BUILDS_TELEMETRY)) return { kind: "off" };
|
|
142
|
+
const endpoint = env.TS_BUILDS_TELEMETRY_URL?.trim() ?? "";
|
|
143
|
+
if (endpoint === "") return {
|
|
144
|
+
kind: "off",
|
|
145
|
+
warning: "TS_BUILDS_TELEMETRY is on but TS_BUILDS_TELEMETRY_URL isn't set, so nothing was sent."
|
|
146
|
+
};
|
|
147
|
+
if (!isAllowedEndpoint(endpoint)) return {
|
|
148
|
+
kind: "off",
|
|
149
|
+
warning: "TS_BUILDS_TELEMETRY_URL must be an https URL (http only to localhost), so nothing was sent."
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
kind: "send",
|
|
153
|
+
endpoint
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A git remote reduced to `host/owner/repo`, lowercase: no protocol, credentials, port or `.git`.
|
|
158
|
+
* `git@github.com:a/b.git`, `https://user:token@github.com/a/b` and `ssh://git@github.com/a/b`
|
|
159
|
+
* all give `github.com/a/b`.
|
|
160
|
+
*/
|
|
161
|
+
function normalizeRemote(remote) {
|
|
162
|
+
const trimmed = remote.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
163
|
+
const scp = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(trimmed);
|
|
164
|
+
if (scp) return `${scp[1]}/${scp[2]}`.replace(/^\/+/, "").toLowerCase();
|
|
165
|
+
return Try(() => new URL(trimmed)).map((url) => `${url.hostname}${url.pathname}`.replace(/\/+$/, "")).orElse(trimmed).toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
/** The salted id for a remote. Stable across machines and clones, so one repo's packages group together. */
|
|
168
|
+
function projectId(remote) {
|
|
169
|
+
return createHash("sha256").update(`${PROJECT_SALT}:${normalizeRemote(remote)}`).digest("hex").slice(0, 16);
|
|
170
|
+
}
|
|
171
|
+
/** The coding agent driving this run, when its env marker is set. */
|
|
172
|
+
function detectAgent(env) {
|
|
173
|
+
return env.CLAUDECODE === "1" ? Option("claude-code") : Option.none();
|
|
174
|
+
}
|
|
175
|
+
function isCi(env) {
|
|
176
|
+
return isOn(env.CI) || env.GITHUB_ACTIONS === "true";
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The npm package that provides a rule's plugin, splitting the rule id the way ESLint does: at the
|
|
180
|
+
* first slash, or the second for a scoped plugin. `functype/x` is `eslint-plugin-functype`,
|
|
181
|
+
* `@typescript-eslint/x` is `@typescript-eslint/eslint-plugin`, `@scope/name/x` is
|
|
182
|
+
* `@scope/eslint-plugin-name`, and `a/b/c` is `eslint-plugin-a`. Core rules have no plugin.
|
|
183
|
+
*/
|
|
184
|
+
function pluginPackage(ruleId) {
|
|
185
|
+
const parts = ruleId.split("/");
|
|
186
|
+
const first = parts.at(0) ?? "";
|
|
187
|
+
if (parts.length < 2 || first === "") return Option.none();
|
|
188
|
+
if (!first.startsWith("@")) return Option(`eslint-plugin-${first}`);
|
|
189
|
+
const name = parts.length > 2 ? parts.at(1) : void 0;
|
|
190
|
+
return Option(name === void 0 ? `${first}/eslint-plugin` : `${first}/eslint-plugin-${name}`);
|
|
191
|
+
}
|
|
192
|
+
/** An npm package name, so a plugin's `meta.name` can't carry a path or other free text. */
|
|
193
|
+
const NPM_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
194
|
+
/** A semver version, so a plugin's `meta.version` can't carry free text. */
|
|
195
|
+
const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
|
|
196
|
+
const ancestors = (dir) => {
|
|
197
|
+
const parent = dirname(dir);
|
|
198
|
+
return parent === dir ? [dir] : [dir, ...ancestors(parent)];
|
|
199
|
+
};
|
|
200
|
+
const readJson = (path) => Try(() => JSON.parse(readFileSync(path, "utf-8"))).toOption();
|
|
201
|
+
const versionIn = (path) => readJson(path).flatMap((pkg) => typeof pkg.version === "string" ? Option(pkg.version) : Option.none());
|
|
202
|
+
/**
|
|
203
|
+
* A package's installed version, found the way Node finds it: `node_modules/<name>` in `fromDir` or
|
|
204
|
+
* any ancestor. Inside a `node_modules` folder it also checks `<name>` beside it, which is where pnpm
|
|
205
|
+
* puts a package's dependencies (`.pnpm/ts-builds@x/node_modules/eslint-plugin-x`).
|
|
206
|
+
*/
|
|
207
|
+
function installedVersion(name, fromDir) {
|
|
208
|
+
const found = ancestors(fromDir).flatMap((dir) => [join(dir, "node_modules", name, "package.json"), ...basename(dir) === "node_modules" ? [join(dir, name, "package.json")] : []]).find((path) => existsSync(path));
|
|
209
|
+
return found === void 0 ? Option.none() : versionIn(found);
|
|
210
|
+
}
|
|
211
|
+
/** ts-builds' own root: the nearest ancestor of this module whose `package.json` is ts-builds. */
|
|
212
|
+
function tsBuildsRoot() {
|
|
213
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
214
|
+
const root = ancestors(here).find((dir) => readJson(join(dir, "package.json")).map((pkg) => pkg.name === "ts-builds").orElse(false));
|
|
215
|
+
return root === void 0 ? Option.none() : Option(root);
|
|
216
|
+
}
|
|
217
|
+
/** The git remote of the repo holding `dir`, if there is one. */
|
|
218
|
+
function gitRemote(dir) {
|
|
219
|
+
return Try(() => execFileSync("git", [
|
|
220
|
+
"config",
|
|
221
|
+
"--get",
|
|
222
|
+
"remote.origin.url"
|
|
223
|
+
], {
|
|
224
|
+
cwd: dir,
|
|
225
|
+
encoding: "utf-8",
|
|
226
|
+
stdio: [
|
|
227
|
+
"ignore",
|
|
228
|
+
"pipe",
|
|
229
|
+
"ignore"
|
|
230
|
+
],
|
|
231
|
+
timeout: 2e3
|
|
232
|
+
}).trim()).toOption().filter((out) => out !== "");
|
|
233
|
+
}
|
|
234
|
+
const severityOf = (entry) => {
|
|
235
|
+
const level = Array.isArray(entry) ? entry[0] : entry;
|
|
236
|
+
if (level === 2 || level === "error") return Option("error");
|
|
237
|
+
if (level === 1 || level === "warn") return Option("warn");
|
|
238
|
+
return Option.none();
|
|
239
|
+
};
|
|
240
|
+
const asRecord = (value) => typeof value === "object" && value !== null ? value : {};
|
|
241
|
+
const asString = (value) => typeof value === "string" && value !== "" ? Option(value) : Option.none();
|
|
242
|
+
/**
|
|
243
|
+
* The plugins and the plugin rules that are on, from `eslint.calculateConfigForFile(...)`. Core
|
|
244
|
+
* rules are left out, and so is ESLint's own `@` namespace.
|
|
245
|
+
*/
|
|
246
|
+
function activeConfig(calculated) {
|
|
247
|
+
const config = asRecord(calculated);
|
|
248
|
+
return {
|
|
249
|
+
plugins: Object.entries(asRecord(config.plugins)).filter(([namespace]) => namespace !== "@").flatMap(([namespace, plugin]) => {
|
|
250
|
+
const meta = asRecord(asRecord(plugin).meta);
|
|
251
|
+
return asString(meta.name).filter((name) => name.length <= 214 && NPM_NAME.test(name)).or(pluginPackage(`${namespace}/rule`)).map((name) => ({
|
|
252
|
+
name,
|
|
253
|
+
metaVersion: asString(meta.version).filter((v) => SEMVER.test(v))
|
|
254
|
+
})).toArray();
|
|
255
|
+
}),
|
|
256
|
+
enabled: Object.fromEntries(Object.entries(asRecord(config.rules)).filter(([id]) => id.includes("/")).flatMap(([id, entry]) => severityOf(entry).map((severity) => [id, severity]).toArray()).toSorted(([a], [b]) => a.localeCompare(b)))
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Versions worth comparing across runs: the toolchain, plus every configured plugin and every plugin
|
|
261
|
+
* whose rules reported. A plugin's installed version wins over the one its `meta` claims, because a
|
|
262
|
+
* hand-written `meta.version` can go stale.
|
|
263
|
+
*/
|
|
264
|
+
function versionsFor(run, rules, active) {
|
|
265
|
+
const root = tsBuildsRoot();
|
|
266
|
+
const lookup = (name) => installedVersion(name, run.dir).or(root.flatMap((dir) => installedVersion(name, dir)));
|
|
267
|
+
const configured = active.map((a) => a.plugins).orElse([]);
|
|
268
|
+
const claimed = (name) => Option(configured.find((p) => p.name === name)).flatMap((p) => p.metaVersion);
|
|
269
|
+
const plugins = [...configured.map((p) => p.name), ...Object.keys(rules).flatMap((id) => pluginPackage(id).toArray())].filter((name, i, all) => all.indexOf(name) === i).toSorted();
|
|
270
|
+
const typescript = lookup("typescript").map((v) => [["typescript", v]]).orElse([]);
|
|
271
|
+
const pluginVersions = plugins.flatMap((name) => lookup(name).or(claimed(name)).map((v) => [name, v]).toArray());
|
|
272
|
+
return {
|
|
273
|
+
node: process.versions.node,
|
|
274
|
+
"ts-builds": root.flatMap((dir) => versionIn(join(dir, "package.json"))).orElse("unknown"),
|
|
275
|
+
eslint: run.eslintVersion,
|
|
276
|
+
...Object.fromEntries([...typescript, ...pluginVersions])
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** The payload for one lint run. Holds counts and versions only: no file paths, source or messages. */
|
|
280
|
+
function buildTelemetryPayload(run, env, active = Option.none()) {
|
|
281
|
+
const stats = ruleStats(run.results);
|
|
282
|
+
const project = gitRemote(run.dir).map(projectId);
|
|
283
|
+
const agent = detectAgent(env);
|
|
284
|
+
return {
|
|
285
|
+
schema: 1,
|
|
286
|
+
...run.packageName.map((name) => ({ package: name })).orElse({}),
|
|
287
|
+
...project.map((id) => ({ project: id })).orElse({}),
|
|
288
|
+
ci: isCi(env),
|
|
289
|
+
...agent.map((name) => ({ agent: name })).orElse({}),
|
|
290
|
+
fix: run.fix,
|
|
291
|
+
durationMs: Math.round(run.durationMs),
|
|
292
|
+
files: run.results.length,
|
|
293
|
+
fatal: stats.fatal,
|
|
294
|
+
versions: versionsFor(run, stats.rules, active),
|
|
295
|
+
...active.map((a) => ({ enabled: a.enabled })).orElse({}),
|
|
296
|
+
rules: stats.rules
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* POST the payload. Resolves `true` on a 2xx and `false` otherwise; never rejects. The send is
|
|
301
|
+
* awaited rather than left in flight, so the run's exit can't cut it off or be held open by it.
|
|
302
|
+
*/
|
|
303
|
+
async function sendTelemetry(payload, endpoint, timeoutMs = TELEMETRY_TIMEOUT_MS) {
|
|
304
|
+
return (await Try.async(async () => {
|
|
305
|
+
const res = await fetch(endpoint, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: { "content-type": "application/json" },
|
|
308
|
+
body: JSON.stringify(payload),
|
|
309
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
310
|
+
});
|
|
311
|
+
await res.body?.cancel();
|
|
312
|
+
return res.ok;
|
|
313
|
+
})).orElse(false);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Report one lint run, as the environment asks. Never throws and never changes the run's result:
|
|
317
|
+
* a failure anywhere in here is dropped.
|
|
318
|
+
*/
|
|
319
|
+
async function reportLintTelemetry(run, env = process.env, log = (line) => process.stderr.write(`${line}\n`)) {
|
|
320
|
+
await Try.async(async () => {
|
|
321
|
+
const mode = telemetryMode(env);
|
|
322
|
+
if (mode.kind === "off") {
|
|
323
|
+
if (mode.warning !== void 0) log(`${LOG_PREFIX} ${mode.warning}`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const payload = buildTelemetryPayload(run, env, (run.configFor === void 0 ? Option.none() : (await Try.async(run.configFor)).toOption()).map(activeConfig));
|
|
327
|
+
if (mode.kind === "debug") {
|
|
328
|
+
log(`${LOG_PREFIX} TS_BUILDS_TELEMETRY_DEBUG is set, so this was printed and not sent:`);
|
|
329
|
+
log(JSON.stringify(payload, null, 2));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
await sendTelemetry(payload, mode.endpoint);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
68
336
|
// src/cli/lint-report.ts
|
|
69
337
|
/**
|
|
70
338
|
* Current sidecar schema version. Bump when the shape changes incompatibly so
|
|
@@ -75,14 +343,15 @@ const LINT_REPORT_VERSION = 1;
|
|
|
75
343
|
function lintReportPath(dir = targetDir) {
|
|
76
344
|
return join(dir, ".ts-builds", "lint-report.json");
|
|
77
345
|
}
|
|
78
|
-
/**
|
|
79
|
-
function
|
|
346
|
+
/** The `name` in `<dir>/package.json`, when the file exists, parses and has a non-empty name. */
|
|
347
|
+
function packageJsonName(dir = targetDir) {
|
|
80
348
|
const pkgPath = join(dir, "package.json");
|
|
81
|
-
if (existsSync(pkgPath))
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
349
|
+
if (!existsSync(pkgPath)) return Option.none();
|
|
350
|
+
return Try(() => JSON.parse(readFileSync(pkgPath, "utf-8")).name).toOption().flatMap((name) => typeof name === "string" && name.length > 0 ? Option(name) : Option.none());
|
|
351
|
+
}
|
|
352
|
+
/** Package name from `<dir>/package.json`, falling back to the directory name. For local use only. */
|
|
353
|
+
function readPackageName(dir = targetDir) {
|
|
354
|
+
return packageJsonName(dir).orElse(basename(dir));
|
|
86
355
|
}
|
|
87
356
|
/** Build a report from ESLint results. */
|
|
88
357
|
function buildLintReport(results, fix, pkg) {
|
|
@@ -108,7 +377,8 @@ function buildLintReport(results, fix, pkg) {
|
|
|
108
377
|
filePath: r.filePath,
|
|
109
378
|
errorCount: r.errorCount,
|
|
110
379
|
warningCount: r.warningCount
|
|
111
|
-
}))
|
|
380
|
+
})),
|
|
381
|
+
rules: ruleStats(results).rules
|
|
112
382
|
};
|
|
113
383
|
}
|
|
114
384
|
/** Build a fatal report for a run that threw before producing results. */
|
|
@@ -379,8 +649,20 @@ function writeAll(stream, data) {
|
|
|
379
649
|
stream.write(data, (err) => err ? reject(err) : resolve());
|
|
380
650
|
});
|
|
381
651
|
}
|
|
652
|
+
/** What `lint` covers: `srcDir`, plus `testDir` when `lint.includeTests` is on and it exists. */
|
|
653
|
+
function lintTargets(config, dir) {
|
|
654
|
+
return config.lint.includeTests && existsSync(resolve(dir, config.testDir)) ? [config.srcDir, config.testDir] : [config.srcDir];
|
|
655
|
+
}
|
|
656
|
+
const isInside = (file, directory) => {
|
|
657
|
+
const path = relative(directory, file);
|
|
658
|
+
return path !== "" && path !== ".." && !path.startsWith(`..${sep}`);
|
|
659
|
+
};
|
|
660
|
+
const notInTsconfig = (result) => result.messages.some((message) => message.fatal === true && message.message.includes("was not found by the project service"));
|
|
382
661
|
/**
|
|
383
|
-
* Lint `srcDir`.
|
|
662
|
+
* Lint `srcDir`, and `testDir` with `lint.includeTests`.
|
|
663
|
+
*
|
|
664
|
+
* Test files no tsconfig includes each fail to parse under a type-aware config. They still
|
|
665
|
+
* count as errors, but print as one message that names the fix, not one parse error per file.
|
|
384
666
|
*
|
|
385
667
|
* Bundled path (default, `useProjectEslint: false`): runs ESLint through its Node
|
|
386
668
|
* API in a single pass, so we get the familiar stylish output AND exact issue
|
|
@@ -409,8 +691,8 @@ function writeAll(stream, data) {
|
|
|
409
691
|
async function runLint(check = false) {
|
|
410
692
|
const config = loadConfig();
|
|
411
693
|
if (config.lint.useProjectEslint) {
|
|
412
|
-
const
|
|
413
|
-
return runCommand("npx eslint",
|
|
694
|
+
const targets = lintTargets(config, targetDir);
|
|
695
|
+
return runCommand("npx eslint", check ? targets : ["--fix", ...targets]);
|
|
414
696
|
}
|
|
415
697
|
const fix = !check;
|
|
416
698
|
const pkg = readPackageName(targetDir);
|
|
@@ -420,14 +702,29 @@ async function runLint(check = false) {
|
|
|
420
702
|
fix,
|
|
421
703
|
cwd: targetDir
|
|
422
704
|
});
|
|
423
|
-
const
|
|
705
|
+
const startedAt = performance.now();
|
|
706
|
+
const results = await eslint.lintFiles(lintTargets(config, targetDir));
|
|
424
707
|
if (fix) await ESLint.outputFixes(results);
|
|
708
|
+
const durationMs = performance.now() - startedAt;
|
|
425
709
|
const report = buildLintReport(results, fix, pkg);
|
|
426
710
|
await writeLintReport(report, targetDir);
|
|
427
711
|
try {
|
|
428
|
-
const
|
|
712
|
+
const testDir = resolve(targetDir, config.testDir);
|
|
713
|
+
const unparsed = config.lint.includeTests ? results.filter((result) => isInside(result.filePath, testDir) && notInTsconfig(result)) : [];
|
|
714
|
+
const output = await (await eslint.loadFormatter("stylish")).format(results.filter((result) => !unparsed.includes(result)));
|
|
429
715
|
if (output) await writeAll(process.stdout, output);
|
|
716
|
+
if (unparsed.length > 0) await writeAll(process.stdout, `\n✗ ${unparsed.length} file(s) in ${config.testDir} aren't in a tsconfig, so type-aware rules can't read them.\n Add "${relative(targetDir, testDir).split(sep).join("/")}/**/*" to "include" in tsconfig.json, or point parserOptions.project at a tsconfig.eslint.json that has it.\n`);
|
|
430
717
|
} catch {}
|
|
718
|
+
const firstFile = results.at(0)?.filePath;
|
|
719
|
+
await reportLintTelemetry({
|
|
720
|
+
results,
|
|
721
|
+
fix,
|
|
722
|
+
durationMs,
|
|
723
|
+
packageName: packageJsonName(targetDir),
|
|
724
|
+
eslintVersion: ESLint.version,
|
|
725
|
+
dir: targetDir,
|
|
726
|
+
configFor: () => firstFile === void 0 ? Promise.resolve(void 0) : eslint.calculateConfigForFile(firstFile)
|
|
727
|
+
});
|
|
431
728
|
return report.errorCount > 0 ? 1 : 0;
|
|
432
729
|
} catch (err) {
|
|
433
730
|
console.error(err.message);
|
|
@@ -983,6 +1280,81 @@ function findWorkspaceRoot(dir) {
|
|
|
983
1280
|
const parent = dirname(dir);
|
|
984
1281
|
return parent === dir ? Option.none() : findWorkspaceRoot(parent);
|
|
985
1282
|
}
|
|
1283
|
+
/** The `packages:` globs of a `pnpm-workspace.yaml`, quotes stripped. */
|
|
1284
|
+
function readWorkspacePackageGlobs(yaml) {
|
|
1285
|
+
return readListBlock(yaml, /^packages:/);
|
|
1286
|
+
}
|
|
1287
|
+
const GLOB_TOKEN = /\*\*\/|\/\*\*$|\*\*|\*|\?|[^*?]/g;
|
|
1288
|
+
const GLOB_TOKEN_REGEX = {
|
|
1289
|
+
"**/": "(?:.*/)?",
|
|
1290
|
+
"/**": "(?:/.*)?",
|
|
1291
|
+
"**": ".*",
|
|
1292
|
+
"*": "[^/]*",
|
|
1293
|
+
"?": "[^/]"
|
|
1294
|
+
};
|
|
1295
|
+
const normalizeGlobPath = (path) => path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
1296
|
+
function globRegExp(glob) {
|
|
1297
|
+
const body = normalizeGlobPath(glob).replace(GLOB_TOKEN, (token) => GLOB_TOKEN_REGEX[token] ?? token.replace(/[.+^${}()|[\]\\]/g, "\\$&"));
|
|
1298
|
+
return new RegExp(`^${body}$`);
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Pure matcher: whether pnpm's `packages:` globs list `relDir`, a directory path
|
|
1302
|
+
* relative to the workspace root. Covers the forms pnpm documents: `*` (within one
|
|
1303
|
+
* path segment), `**` (any depth, including none), `?`, and a leading `!` to exclude.
|
|
1304
|
+
* Listed means some include matches and no exclude does.
|
|
1305
|
+
*/
|
|
1306
|
+
function isListedPackage(relDir, globs) {
|
|
1307
|
+
const path = normalizeGlobPath(relDir);
|
|
1308
|
+
const matches = (glob) => globRegExp(glob).test(path);
|
|
1309
|
+
const includes = globs.filter((glob) => !glob.startsWith("!"));
|
|
1310
|
+
const excludes = globs.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1));
|
|
1311
|
+
return includes.some(matches) && !excludes.some(matches);
|
|
1312
|
+
}
|
|
1313
|
+
/** The workspace root strictly above `dir` whose `packages:` globs list it, if any. */
|
|
1314
|
+
function findListingRoot(dir) {
|
|
1315
|
+
const parent = dirname(dir);
|
|
1316
|
+
if (parent === dir) return Option.none();
|
|
1317
|
+
return findWorkspaceRoot(parent).filter((root) => isListedPackage(relative(root, dir), readWorkspacePackageGlobs(readFileSync(join(root, "pnpm-workspace.yaml"), "utf-8"))));
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* For a package with its own `pnpm-workspace.yaml` that a workspace root above
|
|
1321
|
+
* lists, that root. pnpm treats only a directory with a `package.json` as a
|
|
1322
|
+
* workspace package, and a nested file in an unlisted directory is a legitimate
|
|
1323
|
+
* separate workspace (pnpm 12 creates one there itself), so both are required.
|
|
1324
|
+
*/
|
|
1325
|
+
function strayWorkspaceRoot(dir) {
|
|
1326
|
+
return isFile(join(dir, "pnpm-workspace.yaml")) && existsSync(join(dir, "package.json")) ? findListingRoot(dir) : Option.none();
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Directories below `dir` holding their own `pnpm-workspace.yaml`, without
|
|
1330
|
+
* descending into one once found. Skips hidden directories, `node_modules` and
|
|
1331
|
+
* symlinks, like the lint-report walk; an unreadable directory counts as empty.
|
|
1332
|
+
*/
|
|
1333
|
+
function nestedWorkspaceDirs(dir) {
|
|
1334
|
+
return Try(() => readdirSync(dir, { withFileTypes: true })).orElse([]).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules").map((entry) => join(dir, entry.name)).flatMap((sub) => isFile(join(sub, "pnpm-workspace.yaml")) ? [sub] : nestedWorkspaceDirs(sub));
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Every stray file `doctor` should report from `dir`: `dir` itself when it is a
|
|
1338
|
+
* stray package, and, when `dir` is a workspace root, each package below it that
|
|
1339
|
+
* its globs list and that has its own `pnpm-workspace.yaml`. Returned as
|
|
1340
|
+
* `[root, packageDir]` pairs.
|
|
1341
|
+
*/
|
|
1342
|
+
function findStrayWorkspaceFiles(dir) {
|
|
1343
|
+
const asPackage = strayWorkspaceRoot(dir).fold(() => [], (root) => [[root, dir]]);
|
|
1344
|
+
const wsPath = join(dir, "pnpm-workspace.yaml");
|
|
1345
|
+
const globs = isFile(wsPath) ? readWorkspacePackageGlobs(readFileSync(wsPath, "utf-8")) : [];
|
|
1346
|
+
const asRoot = globs.length === 0 ? [] : nestedWorkspaceDirs(dir).filter((sub) => existsSync(join(sub, "package.json")) && isListedPackage(relative(dir, sub), globs)).map((sub) => [dir, sub]);
|
|
1347
|
+
return [...asPackage, ...asRoot];
|
|
1348
|
+
}
|
|
1349
|
+
function detectStrayWorkspaceFiles(dir) {
|
|
1350
|
+
return findStrayWorkspaceFiles(dir).map(([root, pkgDir]) => {
|
|
1351
|
+
const rel = relative(root, pkgDir).split(sep).join("/");
|
|
1352
|
+
return {
|
|
1353
|
+
severity: "warning",
|
|
1354
|
+
message: `${rel}/pnpm-workspace.yaml sits inside a package that the workspace root (${root}) lists. Its settings don't apply to the workspace, and pnpm run from ${rel} treats it as a separate workspace: it ignores the root's settings and packageManager pin and writes its own pnpm-lock.yaml. Move any settings it holds to the root's pnpm-workspace.yaml and delete it, or add "!${rel}" to the root's packages to keep it a separate workspace`
|
|
1355
|
+
};
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
986
1358
|
function snapshotFile(path) {
|
|
987
1359
|
return {
|
|
988
1360
|
path,
|
|
@@ -1091,7 +1463,8 @@ function detectPnpm11Issues(dir = targetDir, releaseAgeProbe = defaultReleaseAge
|
|
|
1091
1463
|
...hoistIssues,
|
|
1092
1464
|
...pnpmFieldIssues,
|
|
1093
1465
|
...detectAllowBuildsIssues(dir),
|
|
1094
|
-
...detectReleaseAgeIssues(dir, releaseAgeProbe)
|
|
1466
|
+
...detectReleaseAgeIssues(dir, releaseAgeProbe),
|
|
1467
|
+
...detectStrayWorkspaceFiles(dir)
|
|
1095
1468
|
];
|
|
1096
1469
|
return List(results.length > 0 ? results : [{
|
|
1097
1470
|
severity: "info",
|
|
@@ -1173,8 +1546,12 @@ function renderExcludeEntry(entry) {
|
|
|
1173
1546
|
* the next top-level key.
|
|
1174
1547
|
*/
|
|
1175
1548
|
function readReleaseAgeExcludeEntries(yaml) {
|
|
1176
|
-
|
|
1177
|
-
|
|
1549
|
+
return Set(readListBlock(yaml, /^minimumReleaseAgeExclude:/));
|
|
1550
|
+
}
|
|
1551
|
+
const YAML_LIST_ENTRY = /^\s*-\s*(["']?)([^"'\s#]+)\1\s*(?:#.*)?$/;
|
|
1552
|
+
/** The unquoted items of the top-level list block whose header matches `header`. */
|
|
1553
|
+
function readListBlock(yaml, header) {
|
|
1554
|
+
return blockLines(yaml, header).map((line) => YAML_LIST_ENTRY.exec(line)?.[2]).filter((entry) => entry !== void 0);
|
|
1178
1555
|
}
|
|
1179
1556
|
/**
|
|
1180
1557
|
* Insert YAML list entries (already rendered as ` - x` lines) into an existing
|
|
@@ -1360,15 +1737,6 @@ function migrateReleaseAgeExcludes(dir, ws, probe) {
|
|
|
1360
1737
|
}],
|
|
1361
1738
|
sourceMutations: []
|
|
1362
1739
|
}));
|
|
1363
|
-
const outerRoot = findWorkspaceRoot(dir).filter((root) => root !== dir);
|
|
1364
|
-
if (outerRoot.isSome()) return outerRoot.fold(() => noChange(ws), (root) => ({
|
|
1365
|
-
ws,
|
|
1366
|
-
actions: [{
|
|
1367
|
-
kind: "manual",
|
|
1368
|
-
message: `${violations.length} package(s) are newer than the minimumReleaseAge cutoff, but this is a workspace package — run 'ts-builds doctor --fix' from the workspace root (${root}) to add them to its pnpm-workspace.yaml`
|
|
1369
|
-
}],
|
|
1370
|
-
sourceMutations: []
|
|
1371
|
-
}));
|
|
1372
1740
|
const covered = readReleaseAgeExcludeEntries(ws).map(releaseAgeExcludeEntry);
|
|
1373
1741
|
const newEntries = List(violations.map(releaseAgeExcludeEntry)).filter((entry) => !covered.has(entry)).distinct().toArray();
|
|
1374
1742
|
if (newEntries.length === 0) return noChange(ws);
|
|
@@ -1400,6 +1768,37 @@ function migrateAllowBuilds(ws, lockfile) {
|
|
|
1400
1768
|
sourceMutations: []
|
|
1401
1769
|
};
|
|
1402
1770
|
}
|
|
1771
|
+
/**
|
|
1772
|
+
* The refusal `--fix` reports from inside a workspace package. pnpm reads
|
|
1773
|
+
* pnpm-workspace.yaml settings only from the workspace root: writing them into the
|
|
1774
|
+
* package's own file would do nothing, and a stray pnpm-workspace.yaml can make pnpm
|
|
1775
|
+
* treat the package as its own workspace root. Moving the package's settings to the
|
|
1776
|
+
* root is not automated, because it could switch on settings that are inert today
|
|
1777
|
+
* (a package's `pnpm.overrides`, say).
|
|
1778
|
+
*/
|
|
1779
|
+
function workspacePackageAction(root) {
|
|
1780
|
+
return {
|
|
1781
|
+
kind: "manual",
|
|
1782
|
+
message: `Nothing changed: this is a workspace package, and pnpm reads these settings only from the workspace root (${root}). Run 'ts-builds doctor --fix' there, and move any settings in this package's .npmrc or package.json "pnpm" field to the root's pnpm-workspace.yaml by hand`
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* The refusal `--fix` reports from a stray package: one with its own
|
|
1787
|
+
* `pnpm-workspace.yaml` that a workspace root lists. pnpm run from here does read
|
|
1788
|
+
* that file, which is the problem, so writing more into it would entrench it.
|
|
1789
|
+
*/
|
|
1790
|
+
function strayPackageAction(root) {
|
|
1791
|
+
return {
|
|
1792
|
+
kind: "manual",
|
|
1793
|
+
message: `Nothing changed: this package's own pnpm-workspace.yaml sits inside the workspace rooted at ${root} (see the warning above). Move its settings to the root's pnpm-workspace.yaml and delete it, then run 'ts-builds doctor --fix' from the root`
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
/** Why `--fix` must not write from `dir`, if it must not: a stray or ordinary workspace package. */
|
|
1797
|
+
function workspaceRefusal(dir) {
|
|
1798
|
+
const stray = strayWorkspaceRoot(dir).map(strayPackageAction);
|
|
1799
|
+
if (stray.isSome()) return stray;
|
|
1800
|
+
return findWorkspaceRoot(dir).filter((root) => root !== dir).map(workspacePackageAction);
|
|
1801
|
+
}
|
|
1403
1802
|
function migratePnpm11(dir = targetDir, releaseAgeProbe = defaultReleaseAgeProbe) {
|
|
1404
1803
|
const wsPath = join(dir, "pnpm-workspace.yaml");
|
|
1405
1804
|
const originalWs = existsSync(wsPath) && statSync(wsPath).isFile() ? readFileSync(wsPath, "utf-8") : "";
|
|
@@ -1417,6 +1816,11 @@ function migratePnpm11(dir = targetDir, releaseAgeProbe = defaultReleaseAgeProbe
|
|
|
1417
1816
|
];
|
|
1418
1817
|
const { ws } = allowBuilds;
|
|
1419
1818
|
const actions = steps.flatMap((step) => step.actions);
|
|
1819
|
+
const refusal = ws !== originalWs || steps.some((step) => step.sourceMutations.length > 0) ? workspaceRefusal(dir) : Option.none();
|
|
1820
|
+
if (refusal.isSome()) return {
|
|
1821
|
+
actions: [refusal.orThrow()],
|
|
1822
|
+
errors: 0
|
|
1823
|
+
};
|
|
1420
1824
|
if (ws !== originalWs && !safeWrite(wsPath, ws)) return {
|
|
1421
1825
|
actions: [...actions, {
|
|
1422
1826
|
kind: "manual",
|
|
@@ -1702,9 +2106,9 @@ function createConfig(force = false) {
|
|
|
1702
2106
|
console.log(`
|
|
1703
2107
|
Configuration options:
|
|
1704
2108
|
srcDir Source directory for linting (default: "./src")
|
|
1705
|
-
testDir Test directory (default: "./test")
|
|
2109
|
+
testDir Test directory (default: "./test"); linted with lint.includeTests
|
|
1706
2110
|
buildMode "tsdown" (default, libraries) or "vite" (SPAs/React apps)
|
|
1707
|
-
lint Lint settings: { "useProjectEslint": true }
|
|
2111
|
+
lint Lint settings: { "useProjectEslint": true, "includeTests": true }
|
|
1708
2112
|
validateChain Commands to run for validate (default shown above)
|
|
1709
2113
|
commands Custom commands: { "name": "shell command" }
|
|
1710
2114
|
chains Named chains: { "validate:fast": ["format", "lint"] }
|
package/eslint.config.fp.js
CHANGED
package/package-scripts.json
CHANGED
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
"required": true
|
|
18
18
|
},
|
|
19
19
|
"lint": {
|
|
20
|
-
"command": "
|
|
20
|
+
"command": "ts-builds lint",
|
|
21
21
|
"description": "Lint and auto-fix with ESLint",
|
|
22
22
|
"required": true
|
|
23
23
|
},
|
|
24
24
|
"lint:check": {
|
|
25
|
-
"command": "
|
|
25
|
+
"command": "ts-builds lint:check",
|
|
26
26
|
"description": "Check ESLint without fixing",
|
|
27
27
|
"required": true
|
|
28
28
|
},
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"scripts": {
|
|
88
88
|
"validate": "pnpm format && pnpm lint && pnpm test && pnpm build",
|
|
89
89
|
"format": "prettier --write .",
|
|
90
|
-
"lint": "
|
|
90
|
+
"lint": "ts-builds lint",
|
|
91
91
|
"test": "vitest run",
|
|
92
92
|
"build": "ts-builds build"
|
|
93
93
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-builds",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Shared TypeScript configuration files for library templates. Provides standardized ESLint, Prettier, Vitest, TypeScript, and build configs.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -66,9 +66,9 @@
|
|
|
66
66
|
"@vitest/coverage-v8": "^5.0.3",
|
|
67
67
|
"@vitest/ui": "^5.0.3",
|
|
68
68
|
"eslint": "^10.10.0",
|
|
69
|
-
"eslint-config-functype": "^2.
|
|
69
|
+
"eslint-config-functype": "^2.124.1",
|
|
70
70
|
"eslint-config-prettier": "^10.1.8",
|
|
71
|
-
"eslint-plugin-functype": "^2.
|
|
71
|
+
"eslint-plugin-functype": "^2.124.1",
|
|
72
72
|
"eslint-plugin-prettier": "^5.5.6",
|
|
73
73
|
"eslint-plugin-simple-import-sort": "^14.0.0",
|
|
74
74
|
"functype": "^1.11.0",
|