ts-builds 4.1.0 → 4.3.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 +111 -0
- package/dist/cli.js +368 -38
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -221,6 +221,23 @@ npx ts-builds validate:core
|
|
|
221
221
|
npx ts-builds validate:landing
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
+
A `commands` entry named after a built-in step (`format`, `format:check`, `lint`, `lint:check`, `typecheck`,
|
|
225
|
+
`ts-types`, `test`, `test:watch`, `test:coverage`, `test:ui`, `build`, `build:watch`, `dev`, `preview`) replaces
|
|
226
|
+
that step everywhere: inside a chain, and when you run it directly (`ts-builds lint:check`, or
|
|
227
|
+
`ts-builds lint check`). A chain with one of those names takes precedence over both, as it does inside a chain.
|
|
228
|
+
`compile` can be overridden too, but only runs inside a chain. An override runs your command instead of
|
|
229
|
+
ts-builds' lint, so it sends no lint telemetry, and an override of `lint` or `lint:check` removes any old
|
|
230
|
+
`.ts-builds/lint-report.json`, so `lint:summary` can't read stale counts.
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"commands": {
|
|
235
|
+
"lint": "eslint ./src ./test --fix --max-warnings=0",
|
|
236
|
+
"lint:check": "eslint ./src ./test --max-warnings=0"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
224
241
|
### Aggregating lint results across a monorepo
|
|
225
242
|
|
|
226
243
|
Under a task runner, `ts-builds` runs per-package, so each package's ESLint output
|
|
@@ -254,6 +271,100 @@ Wire-up:
|
|
|
254
271
|
aggregate a different root. Finding zero sidecars exits nonzero — an empty sweep
|
|
255
272
|
must not silently pass CI.
|
|
256
273
|
|
|
274
|
+
The sidecar also has a `rules` field with per-rule counts: errors, warnings, findings
|
|
275
|
+
silenced by an `eslint-disable` comment.
|
|
276
|
+
|
|
277
|
+
### Lint telemetry (opt-in)
|
|
278
|
+
|
|
279
|
+
`ts-builds lint` can send each run's per-rule counts to an endpoint you run. That shows which
|
|
280
|
+
rules fire, which ones people silence, and how both change between releases. It's off unless you
|
|
281
|
+
turn it on. ts-builds reads these settings from the environment only, so `ts-builds.config.json`
|
|
282
|
+
has no switch for it. A committed script, workflow or config command that sets them still turns it
|
|
283
|
+
on for everyone who runs it, so set them only where you mean to, such as your own CI.
|
|
284
|
+
|
|
285
|
+
| Env var | Effect |
|
|
286
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
287
|
+
| `TS_BUILDS_TELEMETRY=1` | Turns it on. |
|
|
288
|
+
| `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. |
|
|
289
|
+
| `TS_BUILDS_TELEMETRY_DEBUG=1` | Prints the payload to stderr and sends nothing, whether or not telemetry is on. |
|
|
290
|
+
| `DO_NOT_TRACK=1` | Turns it off, whatever else is set. |
|
|
291
|
+
|
|
292
|
+
Each run sends one JSON object:
|
|
293
|
+
|
|
294
|
+
```json
|
|
295
|
+
{
|
|
296
|
+
"schema": 1,
|
|
297
|
+
"package": "@acme/api",
|
|
298
|
+
"project": "3f9c2a1b7d4e6f80",
|
|
299
|
+
"ci": true,
|
|
300
|
+
"agent": "claude-code",
|
|
301
|
+
"fix": false,
|
|
302
|
+
"durationMs": 1840,
|
|
303
|
+
"files": 212,
|
|
304
|
+
"fatal": 0,
|
|
305
|
+
"versions": {
|
|
306
|
+
"node": "24.20.0",
|
|
307
|
+
"ts-builds": "4.2.0",
|
|
308
|
+
"eslint": "10.14.0",
|
|
309
|
+
"typescript": "6.0.3",
|
|
310
|
+
"eslint-plugin-functype": "2.125.1"
|
|
311
|
+
},
|
|
312
|
+
"enabled": { "functype/no-let": "error", "functype/prefer-option": "warn" },
|
|
313
|
+
"rules": { "functype/prefer-option": { "errors": 0, "warnings": 3, "silenced": 2 } }
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
- **Never sent:** file paths, source code or message text.
|
|
318
|
+
- **Sent as-is:** the `package.json` name, left out when there isn't one, and rule ids as your config
|
|
319
|
+
names them. A plugin's declared name is used only when it looks like an npm package name, and
|
|
320
|
+
otherwise is replaced by the name derived from its rule prefix, such as `eslint-plugin-<prefix>`. Its
|
|
321
|
+
declared version is used only when it's semver.
|
|
322
|
+
- **`project`:** a salted hash of the git remote, so one repo's packages group together. It's a
|
|
323
|
+
stable pseudonym, not anonymity: anyone who knows the remote URL can compute it. It's left out
|
|
324
|
+
when there's no remote.
|
|
325
|
+
- **`agent`:** set when a coding agent runs the lint. Today that's Claude Code, from `CLAUDECODE=1`.
|
|
326
|
+
- **`versions`:** Node, ts-builds, ESLint, TypeScript, and each configured plugin. A plugin's
|
|
327
|
+
installed version wins over the `meta.version` it declares, which is hand-written and can go stale.
|
|
328
|
+
When the installed package can't be found, the `meta.version` is used.
|
|
329
|
+
- **`enabled`:** the plugin rules that are on, with their severity, read from the first linted file's
|
|
330
|
+
config. It tells a rule with no findings apart from a rule that's off. Core rules are left out.
|
|
331
|
+
|
|
332
|
+
It never changes a run's result. It sends after lint finishes and its output is printed, the send
|
|
333
|
+
waits at most 2 seconds, and it ignores every error, so the exit code and output stay the same. A run that
|
|
334
|
+
fails before linting, such as one with a broken config, sends nothing.
|
|
335
|
+
|
|
336
|
+
It doesn't cover `lint.useProjectEslint`, which spawns your own ESLint so ts-builds never sees the
|
|
337
|
+
results. It also doesn't cover scripts that call `eslint` directly instead of `ts-builds lint`.
|
|
338
|
+
|
|
339
|
+
To turn it on in GitHub Actions, set both variables on the job:
|
|
340
|
+
|
|
341
|
+
```yaml
|
|
342
|
+
env:
|
|
343
|
+
TS_BUILDS_TELEMETRY: "1"
|
|
344
|
+
TS_BUILDS_TELEMETRY_URL: ${{ vars.TS_BUILDS_TELEMETRY_URL }}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Under Turbo's strict env mode, a task only sees the variables Turbo is told about, plus a built-in
|
|
348
|
+
set that includes `CI`. List these so `ts-builds lint` sees them. Adding `globalPassThroughEnv` also
|
|
349
|
+
turns on strict mode for every task, so check that your other tasks still get the variables they need:
|
|
350
|
+
|
|
351
|
+
```jsonc
|
|
352
|
+
// turbo.json
|
|
353
|
+
{
|
|
354
|
+
"globalPassThroughEnv": [
|
|
355
|
+
"TS_BUILDS_TELEMETRY",
|
|
356
|
+
"TS_BUILDS_TELEMETRY_URL",
|
|
357
|
+
"TS_BUILDS_TELEMETRY_DEBUG",
|
|
358
|
+
"DO_NOT_TRACK",
|
|
359
|
+
"GITHUB_ACTIONS",
|
|
360
|
+
"CLAUDECODE",
|
|
361
|
+
],
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Pass-through variables don't change Turbo's cache key. A cached lint task doesn't run, so it sends
|
|
366
|
+
nothing.
|
|
367
|
+
|
|
257
368
|
## Extendable Configs
|
|
258
369
|
|
|
259
370
|
ts-builds exports base configurations you can extend:
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,8 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
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
|
|
|
@@ -68,6 +69,270 @@ function loadConfig() {
|
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
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
|
+
|
|
71
336
|
// src/cli/lint-report.ts
|
|
72
337
|
/**
|
|
73
338
|
* Current sidecar schema version. Bump when the shape changes incompatibly so
|
|
@@ -78,14 +343,22 @@ const LINT_REPORT_VERSION = 1;
|
|
|
78
343
|
function lintReportPath(dir = targetDir) {
|
|
79
344
|
return join(dir, ".ts-builds", "lint-report.json");
|
|
80
345
|
}
|
|
81
|
-
/**
|
|
82
|
-
|
|
346
|
+
/**
|
|
347
|
+
* Remove the sidecar. When a `commands` override replaces `lint` or `lint:check`, ts-builds' own lint
|
|
348
|
+
* doesn't run and writes no report, so an old one would leave `lint:summary` reading stale counts.
|
|
349
|
+
*/
|
|
350
|
+
function forgetLintReport(dir = targetDir) {
|
|
351
|
+
rmSync(lintReportPath(dir), { force: true });
|
|
352
|
+
}
|
|
353
|
+
/** The `name` in `<dir>/package.json`, when the file exists, parses and has a non-empty name. */
|
|
354
|
+
function packageJsonName(dir = targetDir) {
|
|
83
355
|
const pkgPath = join(dir, "package.json");
|
|
84
|
-
if (existsSync(pkgPath))
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
356
|
+
if (!existsSync(pkgPath)) return Option.none();
|
|
357
|
+
return Try(() => JSON.parse(readFileSync(pkgPath, "utf-8")).name).toOption().flatMap((name) => typeof name === "string" && name.length > 0 ? Option(name) : Option.none());
|
|
358
|
+
}
|
|
359
|
+
/** Package name from `<dir>/package.json`, falling back to the directory name. For local use only. */
|
|
360
|
+
function readPackageName(dir = targetDir) {
|
|
361
|
+
return packageJsonName(dir).orElse(basename(dir));
|
|
89
362
|
}
|
|
90
363
|
/** Build a report from ESLint results. */
|
|
91
364
|
function buildLintReport(results, fix, pkg) {
|
|
@@ -111,7 +384,8 @@ function buildLintReport(results, fix, pkg) {
|
|
|
111
384
|
filePath: r.filePath,
|
|
112
385
|
errorCount: r.errorCount,
|
|
113
386
|
warningCount: r.warningCount
|
|
114
|
-
}))
|
|
387
|
+
})),
|
|
388
|
+
rules: ruleStats(results).rules
|
|
115
389
|
};
|
|
116
390
|
}
|
|
117
391
|
/** Build a fatal report for a run that threw before producing results. */
|
|
@@ -159,9 +433,10 @@ function resolveEnv(env) {
|
|
|
159
433
|
...env
|
|
160
434
|
} : process.env;
|
|
161
435
|
}
|
|
162
|
-
|
|
436
|
+
/** Spawn one shell command line; resolves with its exit code, or 1 if it can't start */
|
|
437
|
+
function spawnShell(commandLine, label, options) {
|
|
163
438
|
return new Promise((resolve) => {
|
|
164
|
-
const child = spawn(
|
|
439
|
+
const child = spawn(commandLine, {
|
|
165
440
|
cwd: resolveCwd(options.cwd),
|
|
166
441
|
env: resolveEnv(options.env),
|
|
167
442
|
stdio: "inherit",
|
|
@@ -171,27 +446,24 @@ function runCommand(command, args, options = {}) {
|
|
|
171
446
|
resolve(code ?? 1);
|
|
172
447
|
});
|
|
173
448
|
child.on("error", (err) => {
|
|
174
|
-
console.error(`Failed to run
|
|
449
|
+
console.error(`Failed to run${label}: ${err.message}`);
|
|
175
450
|
resolve(1);
|
|
176
451
|
});
|
|
177
452
|
});
|
|
178
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Run `command` with `args` through the shell, so `.bin` shims (including
|
|
456
|
+
* Windows `.cmd` files) and commands such as `npx eslint` resolve. `args` are
|
|
457
|
+
* joined into the command line as given, not escaped: quote any argument that
|
|
458
|
+
* holds spaces or shell characters, as `prettierFormatArgs` does. Joining here
|
|
459
|
+
* rather than passing `args` to `spawn` gives the same command line Node built
|
|
460
|
+
* before, without its DEP0190 warning, which flags exactly that unescaped join.
|
|
461
|
+
*/
|
|
462
|
+
function runCommand(command, args, options = {}) {
|
|
463
|
+
return spawnShell([command, ...args].join(" "), ` ${command}`, options);
|
|
464
|
+
}
|
|
179
465
|
function runShellCommand(shellCmd, options = {}) {
|
|
180
|
-
return
|
|
181
|
-
const child = spawn(shellCmd, {
|
|
182
|
-
cwd: resolveCwd(options.cwd),
|
|
183
|
-
env: resolveEnv(options.env),
|
|
184
|
-
stdio: "inherit",
|
|
185
|
-
shell: true
|
|
186
|
-
});
|
|
187
|
-
child.on("close", (code) => {
|
|
188
|
-
resolve(code ?? 1);
|
|
189
|
-
});
|
|
190
|
-
child.on("error", (err) => {
|
|
191
|
-
console.error(`Failed to run: ${err.message}`);
|
|
192
|
-
resolve(1);
|
|
193
|
-
});
|
|
194
|
-
});
|
|
466
|
+
return spawnShell(shellCmd, "", options);
|
|
195
467
|
}
|
|
196
468
|
|
|
197
469
|
// src/cli/commands/build.ts
|
|
@@ -339,7 +611,7 @@ function prettierConfigArg() {
|
|
|
339
611
|
* no prettier config of their own (see `prettierConfigArg`), so we fill the gap without ever
|
|
340
612
|
* overriding a config they wrote.
|
|
341
613
|
*
|
|
342
|
-
* Bundled paths are quoted because `runCommand`
|
|
614
|
+
* Bundled paths are quoted because `runCommand` runs one shell command line (the shell strips the
|
|
343
615
|
* quotes), keeping them correct when the install path contains spaces.
|
|
344
616
|
*/
|
|
345
617
|
function prettierFormatArgs(check, bundledIgnore = bundledPrettierIgnorePath(), bundledConfig = prettierConfigArg()) {
|
|
@@ -435,8 +707,10 @@ async function runLint(check = false) {
|
|
|
435
707
|
fix,
|
|
436
708
|
cwd: targetDir
|
|
437
709
|
});
|
|
710
|
+
const startedAt = performance.now();
|
|
438
711
|
const results = await eslint.lintFiles(lintTargets(config, targetDir));
|
|
439
712
|
if (fix) await ESLint.outputFixes(results);
|
|
713
|
+
const durationMs = performance.now() - startedAt;
|
|
440
714
|
const report = buildLintReport(results, fix, pkg);
|
|
441
715
|
await writeLintReport(report, targetDir);
|
|
442
716
|
try {
|
|
@@ -446,6 +720,16 @@ async function runLint(check = false) {
|
|
|
446
720
|
if (output) await writeAll(process.stdout, output);
|
|
447
721
|
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`);
|
|
448
722
|
} catch {}
|
|
723
|
+
const firstFile = results.at(0)?.filePath;
|
|
724
|
+
await reportLintTelemetry({
|
|
725
|
+
results,
|
|
726
|
+
fix,
|
|
727
|
+
durationMs,
|
|
728
|
+
packageName: packageJsonName(targetDir),
|
|
729
|
+
eslintVersion: ESLint.version,
|
|
730
|
+
dir: targetDir,
|
|
731
|
+
configFor: () => firstFile === void 0 ? Promise.resolve(void 0) : eslint.calculateConfigForFile(firstFile)
|
|
732
|
+
});
|
|
449
733
|
return report.errorCount > 0 ? 1 : 0;
|
|
450
734
|
} catch (err) {
|
|
451
735
|
console.error(err.message);
|
|
@@ -2126,6 +2410,53 @@ function runSize(args) {
|
|
|
2126
2410
|
function isRunFnCommand(cmd) {
|
|
2127
2411
|
return "runFn" in cmd;
|
|
2128
2412
|
}
|
|
2413
|
+
/** The built-in chain steps. A `commands` entry with one of these names replaces it, in a chain or run directly. */
|
|
2414
|
+
const BUILTIN_STEP_NAMES = [
|
|
2415
|
+
"format",
|
|
2416
|
+
"format:check",
|
|
2417
|
+
"lint",
|
|
2418
|
+
"lint:check",
|
|
2419
|
+
"typecheck",
|
|
2420
|
+
"ts-types",
|
|
2421
|
+
"test",
|
|
2422
|
+
"test:watch",
|
|
2423
|
+
"test:coverage",
|
|
2424
|
+
"test:ui",
|
|
2425
|
+
"build",
|
|
2426
|
+
"build:watch",
|
|
2427
|
+
"dev",
|
|
2428
|
+
"preview",
|
|
2429
|
+
"compile"
|
|
2430
|
+
];
|
|
2431
|
+
const isBuiltinStepName = (name) => BUILTIN_STEP_NAMES.includes(name);
|
|
2432
|
+
/** The step a top-level command runs: `lint check` is `lint:check`, `test watch` is `test:watch`, `test run` is `test` */
|
|
2433
|
+
function stepName(command, subCommand) {
|
|
2434
|
+
if ((command === "lint" || command === "format") && subCommand === "check") return `${command}:check`;
|
|
2435
|
+
if (command === "build" && subCommand === "watch") return "build:watch";
|
|
2436
|
+
if (command === "test" && (subCommand === "watch" || subCommand === "coverage" || subCommand === "ui")) return `test:${subCommand}`;
|
|
2437
|
+
return command;
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* The user's replacement for a built-in step, if their config has one. Run directly, a built-in
|
|
2441
|
+
* resolves the same way it does inside a chain (`runStep`): `config.commands[step] ?? builtin`.
|
|
2442
|
+
*/
|
|
2443
|
+
function builtinOverride(step, config) {
|
|
2444
|
+
return Option(config.commands[step]).filter(() => isBuiltinStepName(step));
|
|
2445
|
+
}
|
|
2446
|
+
/** An override of `lint` or `lint:check` replaces ts-builds' lint, so its old report must go */
|
|
2447
|
+
function forgetLintReportIfLint(step) {
|
|
2448
|
+
if (step === "lint" || step === "lint:check") forgetLintReport();
|
|
2449
|
+
}
|
|
2450
|
+
/** Run a `commands` entry on its own: check its cwd, then run it through the shell */
|
|
2451
|
+
async function runConfiguredCommand(name, cmdDef) {
|
|
2452
|
+
const cwdError = escapingValidateCwdError(name, cmdDef);
|
|
2453
|
+
if (cwdError) {
|
|
2454
|
+
console.error(cwdError);
|
|
2455
|
+
return 1;
|
|
2456
|
+
}
|
|
2457
|
+
forgetLintReportIfLint(name);
|
|
2458
|
+
return runShellCommand(cmdDef.run, { cwd: cmdDef.cwd });
|
|
2459
|
+
}
|
|
2129
2460
|
function getBuiltinCommands(config) {
|
|
2130
2461
|
return {
|
|
2131
2462
|
format: { runFn: () => runFormat(false) },
|
|
@@ -2176,6 +2507,7 @@ async function runStep(step, config, builtins, path) {
|
|
|
2176
2507
|
console.error(cwdError);
|
|
2177
2508
|
return 1;
|
|
2178
2509
|
}
|
|
2510
|
+
if (Object.hasOwn(config.commands, step)) forgetLintReportIfLint(step);
|
|
2179
2511
|
const cwdLabel = "cwd" in cmdDef && cmdDef.cwd ? ` (in ${cmdDef.cwd})` : "";
|
|
2180
2512
|
console.log(`\n▶ Running ${step}...${cwdLabel}`);
|
|
2181
2513
|
const code = isRunFnCommand(cmdDef) ? await cmdDef.runFn() : await runShellCommand(cmdDef.run, { cwd: cmdDef.cwd });
|
|
@@ -2193,6 +2525,12 @@ async function runValidate(chainName = "validate") {
|
|
|
2193
2525
|
// src/cli.ts
|
|
2194
2526
|
const command = process.argv[2] || "init";
|
|
2195
2527
|
const subCommand = process.argv[3];
|
|
2528
|
+
const step = stepName(command, subCommand);
|
|
2529
|
+
if (isBuiltinStepName(step)) {
|
|
2530
|
+
const config = loadConfig();
|
|
2531
|
+
if (Object.hasOwn(config.chains, step)) process.exit(await runValidate(step));
|
|
2532
|
+
await builtinOverride(step, config).fold(() => Promise.resolve(), async (cmd) => process.exit(await runConfiguredCommand(step, cmd)));
|
|
2533
|
+
}
|
|
2196
2534
|
switch (command) {
|
|
2197
2535
|
case "help":
|
|
2198
2536
|
case "--help":
|
|
@@ -2271,16 +2609,8 @@ switch (command) {
|
|
|
2271
2609
|
default: {
|
|
2272
2610
|
const config = loadConfig();
|
|
2273
2611
|
if (config.chains[command]) process.exit(await runValidate(command));
|
|
2274
|
-
else if (config.commands[command])
|
|
2275
|
-
|
|
2276
|
-
const cwdError = escapingValidateCwdError(command, cmdDef);
|
|
2277
|
-
if (cwdError) {
|
|
2278
|
-
console.error(cwdError);
|
|
2279
|
-
process.exit(1);
|
|
2280
|
-
}
|
|
2281
|
-
const code = await runShellCommand(cmdDef.run, { cwd: cmdDef.cwd });
|
|
2282
|
-
process.exit(code);
|
|
2283
|
-
} else {
|
|
2612
|
+
else if (config.commands[command]) process.exit(await runConfiguredCommand(command, config.commands[command]));
|
|
2613
|
+
else {
|
|
2284
2614
|
console.error(`Unknown command: ${command}`);
|
|
2285
2615
|
console.log("Run 'ts-builds help' for usage information.");
|
|
2286
2616
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-builds",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.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.124.
|
|
69
|
+
"eslint-config-functype": "^2.124.1",
|
|
70
70
|
"eslint-config-prettier": "^10.1.8",
|
|
71
|
-
"eslint-plugin-functype": "^2.124.
|
|
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",
|