ts-builds 4.0.0 → 4.0.2

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/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { spawn, spawnSync } from "node:child_process";
9
9
  import { createInterface } from "node:readline";
10
10
  import { gzipSync } from "node:zlib";
11
11
 
12
- //#region src/cli/config.ts
12
+ // src/cli/config.ts
13
13
  const targetDir = process.cwd();
14
14
  /**
15
15
  * Returns true when `cwd` resolves outside `baseDir`. Used to reject the
@@ -65,8 +65,7 @@ function loadConfig() {
65
65
  };
66
66
  }
67
67
 
68
- //#endregion
69
- //#region src/cli/lint-report.ts
68
+ // src/cli/lint-report.ts
70
69
  /**
71
70
  * Current sidecar schema version. Bump when the shape changes incompatibly so
72
71
  * `lint:summary` can detect and skip reports it can't read.
@@ -147,8 +146,7 @@ async function writeLintReport(report, dir = targetDir) {
147
146
  }
148
147
  }
149
148
 
150
- //#endregion
151
- //#region src/cli/process.ts
149
+ // src/cli/process.ts
152
150
  function resolveCwd(cwd) {
153
151
  return cwd ? join(targetDir, cwd) : targetDir;
154
152
  }
@@ -193,8 +191,7 @@ function runShellCommand(shellCmd, options = {}) {
193
191
  });
194
192
  }
195
193
 
196
- //#endregion
197
- //#region src/cli/commands/build.ts
194
+ // src/cli/commands/build.ts
198
195
  const TRANSIENT_RM_ERRORS = Set.of("EBUSY", "EPERM", "ENOTEMPTY", "EMFILE");
199
196
  const RETRY_DELAYS_MS = [
200
197
  100,
@@ -537,8 +534,7 @@ async function runDev() {
537
534
  return loadConfig().buildMode === "vite" ? runCommand("vite", []) : runCommand("tsdown", ["--watch"]);
538
535
  }
539
536
 
540
- //#endregion
541
- //#region src/cli/commands/changelog.ts
537
+ // src/cli/commands/changelog.ts
542
538
  const defaultTypeMap = {
543
539
  feat: "Features",
544
540
  fix: "Bug Fixes",
@@ -709,8 +705,7 @@ function runChangelog(args) {
709
705
  return 0;
710
706
  }
711
707
 
712
- //#endregion
713
- //#region src/cli/commands/info.ts
708
+ // src/cli/commands/info.ts
714
709
  const bundledPackages = [
715
710
  "@eslint/js",
716
711
  "@vitest/coverage-v8",
@@ -846,8 +841,7 @@ Example minimal package.json for SPAs/React apps:
846
841
  `);
847
842
  }
848
843
 
849
- //#endregion
850
- //#region src/cli/commands/cleanup.ts
844
+ // src/cli/commands/cleanup.ts
851
845
  async function cleanup() {
852
846
  const packageJsonPath = join(targetDir, "package.json");
853
847
  if (!existsSync(packageJsonPath)) {
@@ -898,42 +892,56 @@ async function cleanup() {
898
892
  console.log("\nRun 'pnpm install' to update your lockfile.");
899
893
  }
900
894
 
901
- //#endregion
902
- //#region src/cli/pnpm11.ts
895
+ // src/cli/pnpm11.ts
903
896
  const HOIST_LINE = /^public-hoist-pattern\[\]=(.+)$/;
904
- const RELEASE_AGE_LINE = /(@?[^\s@/]+(?:\/[^\s@]+)?@[^\s]+)\s+was published.*minimumReleaseAge/;
897
+ const PKG_VERSION = String.raw`@?[^\s@/]+(?:\/[^\s@]+)?@\S+`;
898
+ const RELEASE_AGE_VIOLATION = new RegExp(`(${PKG_VERSION}) was published (?:at \\S+, )?within the minimumReleaseAge`, "g");
899
+ const AUTO_EXCLUDE_HEADER = /^Added \d+ entr\S* to minimumReleaseAgeExclude/;
900
+ const AUTO_EXCLUDE_ENTRY = new RegExp(`^\\s+(${PKG_VERSION})$`);
905
901
  /**
906
902
  * Pure parser (no I/O): extracts the flagged `pkg@version` tokens from captured
907
- * pnpm output. Order-preserving and de-duplicated. Returns [] for unrelated or
908
- * empty output so callers never emit false warnings.
903
+ * pnpm output, in either the strict or the lenient shape. Order-preserving and
904
+ * de-duplicated. Returns [] for unrelated or empty output so callers never emit
905
+ * false warnings.
909
906
  */
910
907
  function parseReleaseAgeViolations(stdout, stderr) {
911
- const tokens = `${stdout}\n${stderr}`.split("\n").map((line) => RELEASE_AGE_LINE.exec(line.trim())?.[1]).filter((token) => token !== void 0);
912
- return List(tokens).distinct().toArray();
908
+ const output = `${stdout}\n${stderr}`;
909
+ const strict = [...output.replace(/-\r?\n\s+/g, "-").replace(/\s+/g, " ").matchAll(RELEASE_AGE_VIOLATION)].map((match) => match[1]);
910
+ return List([...strict, ...autoExcludedEntries(output)]).distinct().toArray();
911
+ }
912
+ /** The `pkg@version` lines listed under pnpm's lenient-mode "Added N entries" header. */
913
+ function autoExcludedEntries(output) {
914
+ const lines = output.split("\n").map((raw) => raw.trimEnd());
915
+ const start = lines.findIndex((line) => AUTO_EXCLUDE_HEADER.test(line.trim()));
916
+ if (start === -1) return [];
917
+ const body = lines.slice(start + 1);
918
+ const end = body.findIndex((line) => !AUTO_EXCLUDE_ENTRY.test(line));
919
+ return matchFirstGroups(end === -1 ? body : body.slice(0, end), AUTO_EXCLUDE_ENTRY);
913
920
  }
914
921
  /** Pure helper: the suggested pnpm-workspace.yaml exclude line for a flagged token. */
915
922
  function buildReleaseAgeExcludeLine(pkgVersion) {
916
923
  return `minimumReleaseAgeExclude:\n${renderExcludeEntry(releaseAgeExcludeEntry(pkgVersion))}`;
917
924
  }
925
+ /** The command the default probe runs. Exported so warnings and tests name it exactly. */
926
+ const RELEASE_AGE_PROBE_ARGS = ["install", "--lockfile-only"];
918
927
  /**
919
- * Default probe: runs `pnpm install --resolution-only`, which re-runs resolution
920
- * (re-applying minimumReleaseAge) and captures its output. If pnpm is not on PATH
921
- * the spawn errors and we return status -1 so detection degrades quietly.
928
+ * Default probe: runs `pnpm install --lockfile-only`, which resolves anything the
929
+ * lockfile does not already pin (re-applying minimumReleaseAge) without installing
930
+ * packages, and captures its output. `--lockfile-only` exists in pnpm 11 and 12;
931
+ * the `--resolution-only` flag this used before was removed in pnpm 12. If pnpm is
932
+ * not on PATH the spawn errors and we return status -1 so detection degrades quietly.
922
933
  *
923
- * SNAPSHOT/RESTORE: `--resolution-only` is NOT non-mutating in general. When the
924
- * committed `pnpm-lock.yaml` is stale or doesn't match resolution, pnpm REWRITES
925
- * it to match (it only avoids writing when it ABORTS on a violation). A read-only
926
- * diagnostic must never touch a consumer's lockfile, so we snapshot the exact
927
- * bytes before spawning and restore them in a `finally` — putting the file back
928
- * exactly as found (or removing one the probe created where none existed).
929
- * `--resolution-only` does not install packages, so node_modules is not a concern.
934
+ * SNAPSHOT/RESTORE: the run is NOT non-mutating. pnpm rewrites `pnpm-lock.yaml`
935
+ * to match resolution, and in lenient mode it also writes the violating packages
936
+ * into `pnpm-workspace.yaml` itself. A read-only diagnostic must never touch a
937
+ * consumer's files, so we snapshot the exact bytes of both files — in `dir` and at
938
+ * the workspace root pnpm resolves from — and restore them in a `finally`, putting
939
+ * each back exactly as found (or removing one the probe created where none existed).
930
940
  */
931
941
  const defaultReleaseAgeProbe = (dir) => {
932
- const lockPath = join(dir, "pnpm-lock.yaml");
933
- const lockExisted = existsSync(lockPath);
934
- const originalLock = lockExisted ? readFileSync(lockPath) : void 0;
942
+ const snapshots = probeWrittenFiles(dir).map(snapshotFile);
935
943
  try {
936
- const result = spawnSync("pnpm", ["install", "--resolution-only"], {
944
+ const result = spawnSync("pnpm", [...RELEASE_AGE_PROBE_ARGS], {
937
945
  cwd: dir,
938
946
  encoding: "utf-8",
939
947
  env: process.env
@@ -946,13 +954,46 @@ const defaultReleaseAgeProbe = (dir) => {
946
954
  return {
947
955
  stdout: result.stdout ?? "",
948
956
  stderr: result.stderr ?? "",
949
- status: result.status ?? 1
957
+ status: result.status ?? 1,
958
+ autoExcluded: snapshots.flatMap(addedExcludeEntries)
950
959
  };
951
960
  } finally {
952
- if (lockExisted && originalLock !== void 0) writeFileSync(lockPath, originalLock);
953
- else if (!lockExisted && existsSync(lockPath)) rmSync(lockPath, { force: true });
961
+ snapshots.forEach(restoreFile);
954
962
  }
955
963
  };
964
+ /** The `minimumReleaseAgeExclude` entries a `pnpm-workspace.yaml` gained since its snapshot. */
965
+ function addedExcludeEntries({ path, bytes }) {
966
+ if (basename(path) !== "pnpm-workspace.yaml" || !isFile(path)) return [];
967
+ const before = readReleaseAgeExcludeEntries(bytes.fold(() => "", (original) => original.toString("utf-8")));
968
+ return readReleaseAgeExcludeEntries(readFileSync(path, "utf-8")).toArray().filter((entry) => !before.has(entry));
969
+ }
970
+ /**
971
+ * `pnpm-lock.yaml` and `pnpm-workspace.yaml` in `dir` and at its workspace root,
972
+ * if it has one. A path that exists but is not a regular file is left untracked,
973
+ * since pnpm cannot have written it either.
974
+ */
975
+ function probeWrittenFiles(dir) {
976
+ const roots = findWorkspaceRoot(dir).fold(() => [dir], (root) => [dir, root]);
977
+ return List(roots).distinct().toArray().flatMap((root) => [join(root, "pnpm-lock.yaml"), join(root, "pnpm-workspace.yaml")]).filter((path) => !existsSync(path) || isFile(path));
978
+ }
979
+ const isFile = (path) => existsSync(path) && statSync(path).isFile();
980
+ /** The nearest directory at or above `dir` holding a `pnpm-workspace.yaml`. */
981
+ function findWorkspaceRoot(dir) {
982
+ if (isFile(join(dir, "pnpm-workspace.yaml"))) return Option(dir);
983
+ const parent = dirname(dir);
984
+ return parent === dir ? Option.none() : findWorkspaceRoot(parent);
985
+ }
986
+ function snapshotFile(path) {
987
+ return {
988
+ path,
989
+ bytes: existsSync(path) ? Option(readFileSync(path)) : Option.none()
990
+ };
991
+ }
992
+ function restoreFile({ path, bytes }) {
993
+ bytes.fold(() => {
994
+ if (existsSync(path)) rmSync(path, { force: true });
995
+ }, (original) => writeFileSync(path, original));
996
+ }
956
997
  /**
957
998
  * Curated set of packages whose build scripts are known to be unnecessary under
958
999
  * pnpm 11's strictDepBuilds. Extend ONLY after verification — this list drives
@@ -1003,11 +1044,34 @@ function detectAllowBuildsIssues(dir) {
1003
1044
  }));
1004
1045
  }
1005
1046
  function detectReleaseAgeIssues(dir, probe) {
1006
- const { stdout, stderr } = probe(dir);
1007
- return parseReleaseAgeViolations(stdout, stderr).map((pkgVersion) => ({
1047
+ const result = probe(dir);
1048
+ const { stdout, stderr, status } = result;
1049
+ const violations = probeViolations(result);
1050
+ if (violations.length > 0) return violations.map((pkgVersion) => ({
1008
1051
  severity: "warning",
1009
1052
  message: `${pkgVersion} is newer than the pnpm minimumReleaseAge cutoff — add to pnpm-workspace.yaml to allow it:\n ${buildReleaseAgeExcludeLine(pkgVersion)}`
1010
1053
  }));
1054
+ return probeFailure(stdout, stderr, status).map((message) => [{
1055
+ severity: "warning",
1056
+ message
1057
+ }]).orElse([]);
1058
+ }
1059
+ /** Every flagged `pkg@version`, from pnpm's output and from the entries it wrote. */
1060
+ function probeViolations({ stdout, stderr, autoExcluded = [] }) {
1061
+ return List([...parseReleaseAgeViolations(stdout, stderr), ...autoExcluded]).distinct().toArray();
1062
+ }
1063
+ /**
1064
+ * A message when the probe ran pnpm and pnpm failed without reporting a violation,
1065
+ * meaning the release-age check never actually ran. That must not read as "ready".
1066
+ * Status -1 (pnpm not installed) stays quiet: there is nothing to check against.
1067
+ */
1068
+ function probeFailure(stdout, stderr, status) {
1069
+ if (status <= 0) return Option.none();
1070
+ const lines = `${stderr}\n${stdout}`.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
1071
+ const reason = lines.find((line) => line.includes("ERR_PNPM")) ?? lines.at(0);
1072
+ const command = `pnpm ${RELEASE_AGE_PROBE_ARGS.join(" ")}`;
1073
+ const detail = reason === void 0 ? "" : `: ${reason}`;
1074
+ return Option(`Could not check minimumReleaseAge: \`${command}\` exited ${status}${detail}`);
1011
1075
  }
1012
1076
  function readHoistPatterns(npmrc) {
1013
1077
  return npmrc.split("\n").map((line) => HOIST_LINE.exec(line.trim())).filter((m) => m !== null).map((m) => m[1]);
@@ -1103,11 +1167,14 @@ function renderExcludeEntry(entry) {
1103
1167
  }
1104
1168
  /**
1105
1169
  * Pure parser: returns the set of entries already listed under
1106
- * `minimumReleaseAgeExclude:`, normalized (quotes stripped). Same manual
1107
- * line-by-line style as readAllowBuildsKeys — stops at the next top-level key.
1170
+ * `minimumReleaseAgeExclude:`, normalized (quotes stripped). pnpm itself writes
1171
+ * scoped entries single-quoted (`- '@scope/name@1.0.0'`); we write them
1172
+ * double-quoted. Same manual line-by-line style as readAllowBuildsKeys — stops at
1173
+ * the next top-level key.
1108
1174
  */
1109
1175
  function readReleaseAgeExcludeEntries(yaml) {
1110
- return Set(matchFirstGroups(blockLines(yaml, /^minimumReleaseAgeExclude:/), /^\s*-\s*"?([^"]+?)"?\s*$/));
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));
1111
1178
  }
1112
1179
  /**
1113
1180
  * Insert YAML list entries (already rendered as ` - x` lines) into an existing
@@ -1282,8 +1349,26 @@ function migratePackageJsonPnpm(dir, ws) {
1282
1349
  * sourceMutation is needed — it joins the same single ws write.
1283
1350
  */
1284
1351
  function migrateReleaseAgeExcludes(dir, ws, probe) {
1285
- const { stdout, stderr } = probe(dir);
1286
- const violations = parseReleaseAgeViolations(stdout, stderr);
1352
+ const result = probe(dir);
1353
+ const { stdout, stderr, status } = result;
1354
+ const violations = probeViolations(result);
1355
+ if (violations.length === 0) return probeFailure(stdout, stderr, status).fold(() => noChange(ws), (message) => ({
1356
+ ws,
1357
+ actions: [{
1358
+ kind: "skipped",
1359
+ message
1360
+ }],
1361
+ sourceMutations: []
1362
+ }));
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
+ }));
1287
1372
  const covered = readReleaseAgeExcludeEntries(ws).map(releaseAgeExcludeEntry);
1288
1373
  const newEntries = List(violations.map(releaseAgeExcludeEntry)).filter((entry) => !covered.has(entry)).distinct().toArray();
1289
1374
  if (newEntries.length === 0) return noChange(ws);
@@ -1346,8 +1431,7 @@ function migratePnpm11(dir = targetDir, releaseAgeProbe = defaultReleaseAgeProbe
1346
1431
  };
1347
1432
  }
1348
1433
 
1349
- //#endregion
1350
- //#region src/cli/commands/doctor.ts
1434
+ // src/cli/commands/doctor.ts
1351
1435
  function resolveExportValue(value) {
1352
1436
  if (typeof value === "string") return List([value]);
1353
1437
  if (typeof value === "object" && value !== null) return List(Object.values(value)).flatMap((v) => resolveExportValue(v));
@@ -1569,8 +1653,7 @@ function runDoctor(fix = false) {
1569
1653
  });
1570
1654
  }
1571
1655
 
1572
- //#endregion
1573
- //#region src/cli/commands/init.ts
1656
+ // src/cli/commands/init.ts
1574
1657
  const hoistPatterns = [
1575
1658
  "*eslint*",
1576
1659
  "*prettier*",
@@ -1651,8 +1734,7 @@ For cross-package orchestration in a monorepo, use Turbo, nx, or
1651
1734
  `);
1652
1735
  }
1653
1736
 
1654
- //#endregion
1655
- //#region src/cli/commands/lint-summary.ts
1737
+ // src/cli/commands/lint-summary.ts
1656
1738
  /** node_modules is always skipped: huge, and never a workspace package itself. */
1657
1739
  const ALWAYS_SKIP = Set.of("node_modules");
1658
1740
  /** Skipped ONLY when the dir is not itself a package — a workspace package literally
@@ -1797,8 +1879,7 @@ async function runLintSummary(args) {
1797
1879
  return lintSummaryExitCode(totals);
1798
1880
  }
1799
1881
 
1800
- //#endregion
1801
- //#region src/cli/commands/size.ts
1882
+ // src/cli/commands/size.ts
1802
1883
  function measureEntry(dir, entry, absDir) {
1803
1884
  const fullPath = join(absDir, entry);
1804
1885
  return Do(function* () {
@@ -1911,8 +1992,7 @@ function runSize(args) {
1911
1992
  return totalTooBig || !oversized.isEmpty ? 1 : 0;
1912
1993
  }
1913
1994
 
1914
- //#endregion
1915
- //#region src/cli/runner.ts
1995
+ // src/cli/runner.ts
1916
1996
  /**
1917
1997
  * Type guard for the internal `runFn` step shape. A plain `"runFn" in cmd`
1918
1998
  * check leaves `cmd.runFn` typed `unknown` (CommandDef has no such key), so we
@@ -1985,8 +2065,7 @@ async function runValidate(chainName = "validate") {
1985
2065
  return runChain(chainName, loadConfig());
1986
2066
  }
1987
2067
 
1988
- //#endregion
1989
- //#region src/cli.ts
2068
+ // src/cli.ts
1990
2069
  const command = process.argv[2] || "init";
1991
2070
  const subCommand = process.argv[3];
1992
2071
  switch (command) {
@@ -2085,5 +2164,4 @@ switch (command) {
2085
2164
  }
2086
2165
  }
2087
2166
 
2088
- //#endregion
2089
2167
  export { };
@@ -1,4 +1,2 @@
1
1
  import { UserConfig } from "tsdown";
2
- //#region src/tsdown.config.base.d.ts
3
- export declare const tsdown: UserConfig;
4
- //#endregion
2
+ export declare const tsdown: UserConfig;
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
 
3
- //#region src/tsdown.config.base.ts
3
+ // src/tsdown.config.base.ts
4
4
  const env = process.env.NODE_ENV;
5
5
  const tsdown = {
6
6
  sourcemap: true,
@@ -18,5 +18,4 @@ const tsdown = {
18
18
  })
19
19
  };
20
20
 
21
- //#endregion
22
21
  export { tsdown };
@@ -1,6 +1,4 @@
1
1
  import { UserConfig } from "vite";
2
- //#region src/vite.config.base.d.ts
3
2
  /** Base Vite config for SPAs */
4
3
  export declare const vite: UserConfig;
5
- //#endregion
6
4
  export { vite as default };
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
 
3
- //#region src/vite.config.base.ts
3
+ // src/vite.config.base.ts
4
4
  /** Base Vite config for SPAs */
5
5
  const vite = {
6
6
  build: {
@@ -12,5 +12,4 @@ const vite = {
12
12
  resolve: { alias: { "@": resolve(process.cwd(), "src") } }
13
13
  };
14
14
 
15
- //#endregion
16
15
  export { vite as default, vite };
@@ -1,4 +1,2 @@
1
- //#region src/vitest.config.base.d.ts
2
1
  declare const _default: import("vite").UserConfig;
3
- //#endregion
4
2
  export { _default as default };
@@ -1,6 +1,6 @@
1
1
  import { defineConfig } from "vitest/config";
2
2
 
3
- //#region src/vitest.config.base.ts
3
+ // src/vitest.config.base.ts
4
4
  var vitest_config_base_default = defineConfig({ test: {
5
5
  globals: true,
6
6
  environment: "node",
@@ -22,5 +22,4 @@ var vitest_config_base_default = defineConfig({ test: {
22
22
  }
23
23
  } });
24
24
 
25
- //#endregion
26
25
  export { vitest_config_base_default as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-builds",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
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",
@@ -115,5 +115,5 @@
115
115
  "engines": {
116
116
  "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
117
117
  },
118
- "packageManager": "pnpm@11.27.0+sha512.ad12821463bb143295f1fa3ecde77a6b9b97a7990386da800ff57bffc3964dc64b7b1c7559d5dbe5c337aea659ed9c8937d4370bd198ca2009bc6fa0249808e7"
118
+ "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61"
119
119
  }