ts-builds 4.4.0 → 4.5.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 +44 -3
  2. package/dist/cli.js +233 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -319,6 +319,7 @@ on for everyone who runs it, so set them only where you mean to, such as your ow
319
319
  | `TS_BUILDS_TELEMETRY=1` | Turns it on. |
320
320
  | `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. |
321
321
  | `TS_BUILDS_TELEMETRY_DEBUG=1` | Prints the payload to stderr and sends nothing, whether or not telemetry is on. |
322
+ | `TS_BUILDS_TELEMETRY_TRACE=1` | Adds `trace`: the repo, the commit, and each finding's file and line. See below. |
322
323
  | `DO_NOT_TRACK=1` | Turns it off, whatever else is set. |
323
324
 
324
325
  Each run sends one JSON object:
@@ -342,11 +343,17 @@ Each run sends one JSON object:
342
343
  "eslint-plugin-functype": "2.125.1"
343
344
  },
344
345
  "enabled": { "functype/no-let": "error", "functype/prefer-option": "warn" },
345
- "rules": { "functype/prefer-option": { "errors": 0, "warnings": 3, "silenced": 2 } }
346
+ "rules": { "functype/prefer-option": { "errors": 0, "warnings": 3, "silenced": 2 } },
347
+ "messages": {
348
+ "functype/prefer-option": {
349
+ "preferOption": { "errors": 0, "warnings": 3, "silenced": 0 },
350
+ "preferWireForThreeStates": { "errors": 0, "warnings": 0, "silenced": 2 }
351
+ }
352
+ }
346
353
  }
347
354
  ```
348
355
 
349
- - **Never sent:** file paths, source code or message text.
356
+ - **Never sent:** source code or message text. File paths only in `trace`, below.
350
357
  - **Sent as-is:** the `package.json` name, left out when there isn't one, and rule ids as your config
351
358
  names them. A plugin's declared name is used only when it looks like an npm package name, and
352
359
  otherwise is replaced by the name derived from its rule prefix, such as `eslint-plugin-<prefix>`. Its
@@ -360,9 +367,43 @@ Each run sends one JSON object:
360
367
  When the installed package can't be found, the `meta.version` is used.
361
368
  - **`enabled`:** the plugin rules that are on, with their severity, read from the first linted file's
362
369
  config. It tells a rule with no findings apart from a rule that's off. Core rules are left out.
370
+ - **`messages`:** each rule's counts split by the message id it reported. A message id is a fixed
371
+ name in the rule's code, such as `preferOption`, not the message text. It shows which of a rule's
372
+ messages fires or gets silenced. Left out when no finding had one.
373
+
374
+ #### Tracing findings to a line
375
+
376
+ With `TS_BUILDS_TELEMETRY_TRACE=1` as well, the payload adds `trace`, so each finding can be opened at
377
+ the commit that was linted:
378
+
379
+ ```json
380
+ "trace": {
381
+ "repo": "github.com/acme/api",
382
+ "commit": "d53e02a1c9b4e8f7a6b5c4d3e2f1a0b9c8d7e6f5",
383
+ "findings": [
384
+ { "rule": "functype/prefer-option", "message": "preferOption", "kind": "silenced", "file": "src/user.ts", "line": 42 }
385
+ ],
386
+ "dropped": 0
387
+ }
388
+ ```
389
+
390
+ - **What it names:** the repo, from the git remote as `host/owner/repo`, the commit (`HEAD`), and
391
+ each finding's file relative to the repo root and line. It doesn't send code. The code is read at
392
+ the host, under the repo's own access rules.
393
+ - **Turn it on only for repos whose names and file paths you're happy to send.** That's why it's a
394
+ separate flag.
395
+ - **Lines match the commit.** A finding in an untracked file, or in one that differs from `HEAD`
396
+ because it was edited or rewritten by `--fix`, is left out and counted in `dropped`. If git can't
397
+ say which files are tracked or changed, every finding is left out.
398
+ - **Size is capped.** Findings are sorted by file and line. The first 200 are kept, and fewer if
399
+ the payload would pass 60 KB, so a large run still sends its counts. The rest count in `dropped`.
400
+ - **No trace** when the directory isn't in a git repo, has no `HEAD`, or its remote isn't
401
+ `host/owner/repo` with a domain name as the host. A local or relative path isn't traced.
402
+ - **Message ids** that aren't plain identifiers (letters, digits, `_ . : -`, at most 64) are left
403
+ out of `messages` and the trace.
363
404
 
364
405
  It never changes a run's result. It sends after lint finishes and its output is printed, the send
365
- waits at most 2 seconds, and it ignores every error, so the exit code and output stay the same. A run that
406
+ waits at most 2 seconds (the trace's git calls can add a few more), and it ignores every error, so the exit code and output stay the same. A run that
366
407
  fails before linting, such as one with a broken config, sends nothing.
367
408
 
368
409
  It doesn't cover `lint.useProjectEslint`, which spawns your own ESLint so ts-builds never sees the
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync
3
3
  import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { $, Do, List, Map, Option, Set, Try } from "functype";
6
+ import { $, Do, List, Map, Option, Set as Set$1, Try } from "functype";
7
7
  import { Fs, Process } from "functype-os";
8
8
  import { execFileSync, spawn, spawnSync } from "node:child_process";
9
9
  import { createHash } from "node:crypto";
@@ -128,8 +128,12 @@ function loadConfig() {
128
128
  *
129
129
  * Off unless `TS_BUILDS_TELEMETRY=1` is set, and `DO_NOT_TRACK=1` always turns it off. ts-builds
130
130
  * reads these from the environment only. A committed script, workflow or config command that sets
131
- * them still turns it on for everyone who runs it. It never sends file paths, source or messages,
132
- * the send waits at most `TELEMETRY_TIMEOUT_MS`, and it never changes the run's exit code.
131
+ * them still turns it on for everyone who runs it. It never sends source or message text, the send
132
+ * waits at most `TELEMETRY_TIMEOUT_MS`, and it never changes the run's exit code.
133
+ *
134
+ * File paths and the repo's name go out only with a second flag, `TS_BUILDS_TELEMETRY_TRACE=1`. The
135
+ * trace adds the git remote, the commit and each finding's repo-relative file and line, so a finding
136
+ * can be opened at that commit, where the repo's own access rules still apply.
133
137
  */
134
138
  /** Bump when the payload's shape changes incompatibly, so the receiver can tell versions apart. */
135
139
  const TELEMETRY_SCHEMA_VERSION = 1;
@@ -138,6 +142,17 @@ const TELEMETRY_TIMEOUT_MS = 2e3;
138
142
  /** A fixed salt, so the repo id is a stable pseudonym. Anyone who knows the remote URL can recompute it. */
139
143
  const PROJECT_SALT = "ts-builds-telemetry-v1";
140
144
  const LOG_PREFIX = "[ts-builds telemetry]";
145
+ /** Findings in one trace. The receiver takes up to 300. */
146
+ const MAX_TRACE_FINDINGS = 200;
147
+ /**
148
+ * The largest payload, in bytes, a trace may grow to. The receiver rejects bodies over 64 KB and
149
+ * drops the whole run, counts included, so findings that would pass this are dropped instead.
150
+ */
151
+ const MAX_PAYLOAD_BYTES = 6e4;
152
+ /** The receiver's message id pattern. Ids outside it aren't sent at all. */
153
+ const MESSAGE_ID = /^[A-Za-z0-9_.:-]{1,64}$/;
154
+ const messageIdOf = (message) => typeof message.messageId === "string" && MESSAGE_ID.test(message.messageId) ? Option(message.messageId) : Option.none();
155
+ const own = (record, key) => Object.hasOwn(record, key) ? Option(record[key]) : Option.none();
141
156
  const NO_COUNTS = {
142
157
  errors: 0,
143
158
  warnings: 0,
@@ -173,6 +188,66 @@ function ruleStats(results) {
173
188
  fatal: results.reduce((n, r) => n + r.messages.filter((m) => m.ruleId === null).length, 0)
174
189
  };
175
190
  }
191
+ /** Every message with a rule id, reported or silenced, tagged with how it counts. */
192
+ const tagged = (result) => [...result.messages.flatMap((m) => m.ruleId === null ? [] : [{
193
+ message: m,
194
+ kind: m.severity === 2 ? "error" : "warning"
195
+ }]), ...result.suppressedMessages.flatMap((m) => m.ruleId === null ? [] : [{
196
+ message: m,
197
+ kind: "silenced"
198
+ }])];
199
+ const FIELD = {
200
+ error: "errors",
201
+ warning: "warnings",
202
+ silenced: "silenced"
203
+ };
204
+ const sortKeys = (record) => Object.fromEntries(Object.entries(record).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
205
+ /**
206
+ * Per-rule counts split by message id, for the findings that have one. A rule with several messages,
207
+ * such as one that suggests different fixes, then shows which message fires or gets silenced.
208
+ * Message ids are fixed identifiers in the rule, not the message text. An id that doesn't look like
209
+ * one (letters, digits, `_ . : -`, at most 64) is left out. Pure and key-sorted.
210
+ */
211
+ function messageStats(results) {
212
+ const nested = results.flatMap(tagged).reduce((acc, { message, kind }) => {
213
+ const rule = message.ruleId ?? "";
214
+ if (rule === "") return acc;
215
+ return messageIdOf(message).map((id) => {
216
+ const byId = own(acc, rule).orElse({});
217
+ const current = own(byId, id).orElse(NO_COUNTS);
218
+ return {
219
+ ...acc,
220
+ [rule]: {
221
+ ...byId,
222
+ [id]: {
223
+ ...current,
224
+ [FIELD[kind]]: current[FIELD[kind]] + 1
225
+ }
226
+ }
227
+ };
228
+ }).orElse(acc);
229
+ }, {});
230
+ return sortKeys(Object.fromEntries(Object.entries(nested).map(([rule, byId]) => [rule, sortKeys(byId)])));
231
+ }
232
+ /**
233
+ * Every finding as a trace entry, with its file relative to `root` and `/`-separated, sorted by file,
234
+ * line and rule. A file outside `root` has no repo path, so its findings are left out, and not
235
+ * counted in `dropped`.
236
+ */
237
+ function traceCandidates(results, root) {
238
+ return results.flatMap((result) => {
239
+ const rel = relative(root, result.filePath);
240
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return [];
241
+ const file = rel.split(sep).join("/");
242
+ return tagged(result).map(({ message, kind }) => ({
243
+ rule: message.ruleId ?? "",
244
+ ...messageIdOf(message).map((id) => ({ message: id })).orElse({}),
245
+ kind,
246
+ file,
247
+ line: Math.max(1, message.line)
248
+ }));
249
+ }).toSorted((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line || (a.rule < b.rule ? -1 : 1));
250
+ }
176
251
  const LOCAL_HOSTS = [
177
252
  "localhost",
178
253
  "127.0.0.1",
@@ -224,6 +299,10 @@ function projectId(remote) {
224
299
  function detectAgent(env) {
225
300
  return env.CLAUDECODE === "1" ? Option("claude-code") : Option.none();
226
301
  }
302
+ /** Whether to add the trace: repo, commit, and each finding's file and line. */
303
+ function traceOn(env) {
304
+ return isOn(env.TS_BUILDS_TELEMETRY_TRACE);
305
+ }
227
306
  function isCi(env) {
228
307
  return isOn(env.CI) || env.GITHUB_ACTIONS === "true";
229
308
  }
@@ -266,13 +345,9 @@ function tsBuildsRoot() {
266
345
  const root = ancestors(here).find((dir) => readJson(join(dir, "package.json")).map((pkg) => pkg.name === "ts-builds").orElse(false));
267
346
  return root === void 0 ? Option.none() : Option(root);
268
347
  }
269
- /** The git remote of the repo holding `dir`, if there is one. */
270
- function gitRemote(dir) {
271
- return Try(() => execFileSync("git", [
272
- "config",
273
- "--get",
274
- "remote.origin.url"
275
- ], {
348
+ /** A git command's stdout in `dir`, or none when it fails. */
349
+ function git(dir, args) {
350
+ return Try(() => execFileSync("git", [...args], {
276
351
  cwd: dir,
277
352
  encoding: "utf-8",
278
353
  stdio: [
@@ -280,8 +355,101 @@ function gitRemote(dir) {
280
355
  "pipe",
281
356
  "ignore"
282
357
  ],
283
- timeout: 2e3
284
- }).trim()).toOption().filter((out) => out !== "");
358
+ timeout: 2e3,
359
+ maxBuffer: 16777216
360
+ })).toOption();
361
+ }
362
+ const trimmed = (out) => out.map((s) => s.trim()).filter((s) => s !== "");
363
+ /** The git remote of the repo holding `dir`, if there is one. */
364
+ function gitRemote(dir) {
365
+ return trimmed(git(dir, [
366
+ "config",
367
+ "--get",
368
+ "remote.origin.url"
369
+ ]));
370
+ }
371
+ /**
372
+ * `host/owner/repo`, where the host is a domain name. A path remote, relative or absolute, doesn't
373
+ * match, so it isn't traced. No segment may be `.` or `..`, which a link would resolve.
374
+ */
375
+ const TRACE_REPO_SHAPE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)+(\/[a-z0-9._-]{1,100}){2,5}$/;
376
+ const isTraceRepo = (repo) => repo.length <= 300 && TRACE_REPO_SHAPE.test(repo) && repo.split("/").every((s) => s !== "." && s !== "..");
377
+ const COMMIT = /^([0-9a-f]{40}|[0-9a-f]{64})$/;
378
+ /** NUL-separated paths from `git ... -z`, as a set. */
379
+ const pathSet = (out) => new Set(out.split("\0").filter((p) => p !== ""));
380
+ /**
381
+ * The trace for a run: the repo, `HEAD`, and findings in files that are committed as linted. A
382
+ * finding in an untracked file, or in one that differs from `HEAD` (edited, or rewritten by
383
+ * `--fix`), is dropped, because its line wouldn't match the commit. If git can't say which files
384
+ * are tracked or changed, every finding is dropped rather than risk a wrong line. Paths are passed to
385
+ * git literally, so a name like `[slug].astro` isn't read as a pattern.
386
+ */
387
+ function gitTrace(results, dir) {
388
+ const root = trimmed(git(dir, ["rev-parse", "--show-toplevel"]));
389
+ const repo = gitRemote(dir).map(normalizeRemote).filter(isTraceRepo);
390
+ const commit = trimmed(git(dir, ["rev-parse", "HEAD"])).filter((c) => COMMIT.test(c));
391
+ return root.flatMap((top) => repo.flatMap((name) => commit.map((sha) => {
392
+ const candidates = traceCandidates(results, top);
393
+ const capped = candidates.slice(0, 200);
394
+ const files = [...new Set(capped.map((f) => f.file))];
395
+ const listed = (args) => files.length === 0 ? Option(/* @__PURE__ */ new Set()) : git(top, [
396
+ "--literal-pathspecs",
397
+ ...args,
398
+ "--",
399
+ ...files
400
+ ]).map(pathSet);
401
+ const tracked = listed(["ls-files", "-z"]);
402
+ const changed = listed([
403
+ "diff",
404
+ "--name-only",
405
+ "-z",
406
+ "HEAD"
407
+ ]);
408
+ const findings = tracked.flatMap((t) => changed.map((c) => capped.filter((f) => t.has(f.file) && !c.has(f.file)))).orElse([]);
409
+ return {
410
+ repo: name,
411
+ commit: sha,
412
+ findings,
413
+ dropped: candidates.length - findings.length
414
+ };
415
+ })));
416
+ }
417
+ const bytes = (value) => Buffer.byteLength(JSON.stringify(value));
418
+ /**
419
+ * Keep the trace's findings, in order, while the whole payload stays within `maxBytes`. The rest
420
+ * are counted in `dropped`, so a large run still sends its counts rather than being rejected.
421
+ */
422
+ function fitTrace(payload, trace, maxBytes = MAX_PAYLOAD_BYTES) {
423
+ const empty = {
424
+ ...trace,
425
+ findings: [],
426
+ dropped: trace.dropped + trace.findings.length
427
+ };
428
+ const budget = maxBytes - bytes({
429
+ ...payload,
430
+ trace: empty
431
+ }) - 16;
432
+ const kept = trace.findings.reduce((acc, f) => {
433
+ if (acc.full) return acc;
434
+ const size = bytes(f) + 1;
435
+ return acc.used + size > budget ? {
436
+ ...acc,
437
+ full: true
438
+ } : {
439
+ used: acc.used + size,
440
+ n: acc.n + 1,
441
+ full: false
442
+ };
443
+ }, {
444
+ used: 0,
445
+ n: 0,
446
+ full: false
447
+ }).n;
448
+ return {
449
+ ...trace,
450
+ findings: trace.findings.slice(0, kept),
451
+ dropped: trace.dropped + (trace.findings.length - kept)
452
+ };
285
453
  }
286
454
  const severityOf = (entry) => {
287
455
  const level = Array.isArray(entry) ? entry[0] : entry;
@@ -328,12 +496,17 @@ function versionsFor(run, rules, active) {
328
496
  ...Object.fromEntries([...typescript, ...pluginVersions])
329
497
  };
330
498
  }
331
- /** The payload for one lint run. Holds counts and versions only: no file paths, source or messages. */
499
+ /**
500
+ * The payload for one lint run. Holds counts and versions, and no source or message text. File
501
+ * paths and the repo go in `trace`, only with `TS_BUILDS_TELEMETRY_TRACE=1`.
502
+ */
332
503
  function buildTelemetryPayload(run, env, active = Option.none()) {
333
504
  const stats = ruleStats(run.results);
334
505
  const project = gitRemote(run.dir).map(projectId);
335
506
  const agent = detectAgent(env);
336
- return {
507
+ const messages = messageStats(run.results);
508
+ const trace = traceOn(env) ? gitTrace(run.results, run.dir) : Option.none();
509
+ const payload = {
337
510
  schema: 1,
338
511
  ...run.packageName.map((name) => ({ package: name })).orElse({}),
339
512
  ...project.map((id) => ({ project: id })).orElse({}),
@@ -345,8 +518,13 @@ function buildTelemetryPayload(run, env, active = Option.none()) {
345
518
  fatal: stats.fatal,
346
519
  versions: versionsFor(run, stats.rules, active),
347
520
  ...active.map((a) => ({ enabled: a.enabled })).orElse({}),
348
- rules: stats.rules
521
+ rules: stats.rules,
522
+ ...Object.keys(messages).length > 0 ? { messages } : {}
349
523
  };
524
+ return trace.map((t) => ({
525
+ ...payload,
526
+ trace: fitTrace(payload, t)
527
+ })).orElse(payload);
350
528
  }
351
529
  /**
352
530
  * POST the payload. Resolves `true` on a 2xx and `false` otherwise; never rejects. The send is
@@ -519,7 +697,7 @@ function runShellCommand(shellCmd, options = {}) {
519
697
  }
520
698
 
521
699
  // src/cli/commands/build.ts
522
- const TRANSIENT_RM_ERRORS = Set.of("EBUSY", "EPERM", "ENOTEMPTY", "EMFILE");
700
+ const TRANSIENT_RM_ERRORS = Set$1.of("EBUSY", "EPERM", "ENOTEMPTY", "EMFILE");
523
701
  const RETRY_DELAYS_MS = [
524
702
  100,
525
703
  250,
@@ -648,6 +826,32 @@ function consumerHasPrettierConfig(startDir = targetDir) {
648
826
  function prettierConfigArg() {
649
827
  return consumerHasPrettierConfig() ? Option.none() : bundledPrettierConfigPath();
650
828
  }
829
+ /** True when `dir` is a workspace root: it has `pnpm-workspace.yaml`, or a package.json with `workspaces`. */
830
+ function isWorkspaceRoot(dir) {
831
+ if (Fs.existsSync(join(dir, "pnpm-workspace.yaml"))) return true;
832
+ return Fs.readFileSync(join(dir, "package.json")).toOption().flatMap((content) => {
833
+ try {
834
+ return Option(JSON.parse(content).workspaces);
835
+ } catch {
836
+ return Option.none();
837
+ }
838
+ }).fold(() => false, () => true);
839
+ }
840
+ /**
841
+ * The enclosing workspace root's `.prettierignore`, when `startDir` is a package inside a workspace
842
+ * and that file exists. Exported for testing.
843
+ *
844
+ * `format` runs prettier from the package's own directory, and prettier reads `.prettierignore` only
845
+ * from there. So an ignore rule a monorepo states once at its root (a generated file, a vendored
846
+ * snapshot) applied when the repo was formatted from the root and NOT when each package formatted
847
+ * itself: the per-package `format` rewrote the generated file and `format:check` failed on it. Only
848
+ * strict ancestors are searched, because a package that is itself the root already passes its own
849
+ * `./.prettierignore`. Patterns in an `--ignore-path` file resolve relative to that file (prettier 3),
850
+ * so the root's patterns mean the same thing from inside a package.
851
+ */
852
+ function workspacePrettierIgnorePath(startDir = targetDir) {
853
+ return ancestorDirs(startDir).drop(1).find((dir) => isWorkspaceRoot(dir)).map((root) => join(root, ".prettierignore")).filter((path) => Fs.existsSync(path));
854
+ }
651
855
  /**
652
856
  * prettier CLI args for `format` / `format:check`.
653
857
  *
@@ -659,6 +863,9 @@ function prettierConfigArg() {
659
863
  * output are ignored for every consumer with no per-repo `.prettierignore` needed, while the
660
864
  * consumer's own entries still apply.
661
865
  *
866
+ * `workspaceIgnore` adds the enclosing workspace root's `.prettierignore` the same additive way, so a
867
+ * package ignores what its monorepo ignores (see `workspacePrettierIgnorePath`).
868
+ *
662
869
  * `bundledConfig` is the same idea for style rather than ignores: Some only when the consumer has
663
870
  * no prettier config of their own (see `prettierConfigArg`), so we fill the gap without ever
664
871
  * overriding a config they wrote.
@@ -666,14 +873,10 @@ function prettierConfigArg() {
666
873
  * Bundled paths are quoted because `runCommand` runs one shell command line (the shell strips the
667
874
  * quotes), keeping them correct when the install path contains spaces.
668
875
  */
669
- function prettierFormatArgs(check, bundledIgnore = bundledPrettierIgnorePath(), bundledConfig = prettierConfigArg()) {
876
+ function prettierFormatArgs(check, bundledIgnore = bundledPrettierIgnorePath(), bundledConfig = prettierConfigArg(), workspaceIgnore = workspacePrettierIgnorePath()) {
670
877
  const base = List([check ? "--check" : "--write", "."]);
671
- const ignoreArgs = bundledIgnore ? List([
672
- "--ignore-path",
673
- `"${bundledIgnore}"`,
674
- "--ignore-path",
675
- ".prettierignore"
676
- ]) : List.empty();
878
+ const explicit = List([Option(bundledIgnore), workspaceIgnore]).flatMap((path) => path.fold(() => List.empty(), (p) => List(["--ignore-path", `"${p}"`])));
879
+ const ignoreArgs = explicit.isEmpty ? explicit : explicit.concat(List(["--ignore-path", ".prettierignore"]));
677
880
  const configArgs = bundledConfig.fold(() => List.empty(), (path) => List(["--config", `"${path}"`]));
678
881
  return base.concat(ignoreArgs).concat(configArgs).toArray();
679
882
  }
@@ -1443,7 +1646,7 @@ const LOCKFILE_PKG_PRESENCE = (name) => new RegExp(`(^|[/'"\\s])${name}@`, "m");
1443
1646
  * a non-whitespace char and ends with `:`).
1444
1647
  */
1445
1648
  function readAllowBuildsKeys(yaml) {
1446
- return Set(matchFirstGroups(blockLines(yaml, /^allowBuilds:/), /^\s{1,}([^\s:]+)\s*:/));
1649
+ return Set$1(matchFirstGroups(blockLines(yaml, /^allowBuilds:/), /^\s{1,}([^\s:]+)\s*:/));
1447
1650
  }
1448
1651
  /**
1449
1652
  * The lines of the top-level block whose header matches `header`, each trimmed at
@@ -1535,7 +1738,7 @@ function hasPnpmField(pkgPath) {
1535
1738
  return Boolean(pkg.pnpm && typeof pkg.pnpm === "object" && Object.keys(pkg.pnpm).length > 0);
1536
1739
  }
1537
1740
  const NPMRC_HEADER = "# Hoist CLI tool binaries from peer dependencies";
1538
- const KNOWN_PNPM_KEYS = Set.of("overrides", "peerDependencyRules");
1741
+ const KNOWN_PNPM_KEYS = Set$1.of("overrides", "peerDependencyRules");
1539
1742
  function hasTopLevelKey(yaml, key) {
1540
1743
  return new RegExp(`^${key}:`, "m").test(yaml);
1541
1744
  }
@@ -1604,7 +1807,7 @@ function renderExcludeEntry(entry) {
1604
1807
  * the next top-level key.
1605
1808
  */
1606
1809
  function readReleaseAgeExcludeEntries(yaml) {
1607
- return Set(readListBlock(yaml, /^minimumReleaseAgeExclude:/));
1810
+ return Set$1(readListBlock(yaml, /^minimumReleaseAgeExclude:/));
1608
1811
  }
1609
1812
  const YAML_LIST_ENTRY = /^\s*-\s*(["']?)([^"'\s#]+)\1\s*(?:#.*)?$/;
1610
1813
  /** The unquoted items of the top-level list block whose header matches `header`. */
@@ -1747,7 +1950,7 @@ function migratePackageJsonPnpm(dir, ws) {
1747
1950
  if (!pnpm || typeof pnpm !== "object") return noChange(ws);
1748
1951
  const overrides = migratePnpmKey(ws, "overrides", pnpm.overrides, renderOverrides);
1749
1952
  const peerRules = migratePnpmKey(overrides.ws, "peerDependencyRules", pnpm.peerDependencyRules, renderPeerDependencyRules);
1750
- const migratedKeys = Set([...overrides.migrated ? ["overrides"] : [], ...peerRules.migrated ? ["peerDependencyRules"] : []]);
1953
+ const migratedKeys = Set$1([...overrides.migrated ? ["overrides"] : [], ...peerRules.migrated ? ["peerDependencyRules"] : []]);
1751
1954
  const remainingPnpm = Object.fromEntries(Object.entries(pnpm).filter(([key]) => !migratedKeys.has(key)));
1752
1955
  const manual = Object.keys(remainingPnpm).filter((key) => !KNOWN_PNPM_KEYS.has(key)).map((key) => ({
1753
1956
  kind: "manual",
@@ -2198,12 +2401,12 @@ For cross-package orchestration in a monorepo, use Turbo, nx, or
2198
2401
 
2199
2402
  // src/cli/commands/lint-summary.ts
2200
2403
  /** node_modules is always skipped: huge, and never a workspace package itself. */
2201
- const ALWAYS_SKIP = Set.of("node_modules");
2404
+ const ALWAYS_SKIP = Set$1.of("node_modules");
2202
2405
  /** Skipped ONLY when the dir is not itself a package — a workspace package literally
2203
2406
  * named `lib`/`dist`/`coverage` must still be summarized, so we check for package.json.
2204
2407
  * (A publish-from-dist layout with a `package.json` inside `dist/` is therefore
2205
2408
  * traversed — harmless: the walk still only matches `.ts-builds/lint-report.json`.) */
2206
- const SKIP_UNLESS_PACKAGE = Set.of("dist", "lib", "coverage");
2409
+ const SKIP_UNLESS_PACKAGE = Set$1.of("dist", "lib", "coverage");
2207
2410
  /**
2208
2411
  * Recursively find every `.ts-builds/lint-report.json` under `root`.
2209
2412
  *
@@ -2529,7 +2732,7 @@ function getBuiltinCommands(config) {
2529
2732
  compile: { run: "tsc" }
2530
2733
  };
2531
2734
  }
2532
- async function runChain(chainName, config, path = Set.empty()) {
2735
+ async function runChain(chainName, config, path = Set$1.empty()) {
2533
2736
  if (path.has(chainName)) {
2534
2737
  console.error(`Circular chain reference detected: ${chainName}`);
2535
2738
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-builds",
3
- "version": "4.4.0",
3
+ "version": "4.5.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",