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.
Files changed (3) hide show
  1. package/README.md +111 -0
  2. package/dist/cli.js +368 -38
  3. 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
- /** Package name from `<dir>/package.json`, falling back to the directory name. */
82
- function readPackageName(dir = targetDir) {
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)) try {
85
- const { name } = JSON.parse(readFileSync(pkgPath, "utf-8"));
86
- if (typeof name === "string" && name.length > 0) return name;
87
- } catch {}
88
- return basename(dir);
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
- function runCommand(command, args, options = {}) {
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(command, args, {
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 ${command}: ${err.message}`);
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 new Promise((resolve) => {
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` spawns with `shell: true` (the shell strips the
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
- const cmdDef = config.commands[command];
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.1.0",
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.0",
69
+ "eslint-config-functype": "^2.124.1",
70
70
  "eslint-config-prettier": "^10.1.8",
71
- "eslint-plugin-functype": "^2.124.0",
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",