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 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: { useProjectEslint: userConfig.lint?.useProjectEslint ?? false },
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
- /** Package name from `<dir>/package.json`, falling back to the directory name. */
79
- function readPackageName(dir = targetDir) {
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)) try {
82
- const { name } = JSON.parse(readFileSync(pkgPath, "utf-8"));
83
- if (typeof name === "string" && name.length > 0) return name;
84
- } catch {}
85
- return basename(dir);
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 args = check ? [config.srcDir] : ["--fix", config.srcDir];
413
- return runCommand("npx eslint", args);
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 results = await eslint.lintFiles([config.srcDir]);
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 output = await (await eslint.loadFormatter("stylish")).format(results);
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
- const entryLine = /^\s*-\s*(["']?)([^"'\s#]+)\1\s*(?:#.*)?$/;
1177
- return Set(blockLines(yaml, /^minimumReleaseAgeExclude:/).map((line) => entryLine.exec(line)?.[2]).filter((entry) => entry !== void 0));
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"] }
@@ -41,4 +41,6 @@ export default [
41
41
  ...functypeConfig.configs.recommended.rules,
42
42
  },
43
43
  },
44
+ // Last, so it wins over the rules above, and a consumer's own blocks after it win over it.
45
+ functypeConfig.configs.testOverrides,
44
46
  ]
@@ -45,4 +45,7 @@ export default [
45
45
  ...functypePlugin.configs.recommended.rules,
46
46
  },
47
47
  },
48
+ // Last, so they win over the rules above, and a consumer's own blocks after them win over them.
49
+ functypeConfig.configs.testOverrides,
50
+ functypePlugin.configs.tests,
48
51
  ]
@@ -17,12 +17,12 @@
17
17
  "required": true
18
18
  },
19
19
  "lint": {
20
- "command": "eslint ./src --fix",
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": "eslint ./src",
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": "eslint ./src --fix",
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.2",
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.111.0",
69
+ "eslint-config-functype": "^2.124.1",
70
70
  "eslint-config-prettier": "^10.1.8",
71
- "eslint-plugin-functype": "^2.111.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",