ts-builds 4.0.1 → 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.
Files changed (2) hide show
  1. package/dist/cli.js +121 -29
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -894,39 +894,54 @@ async function cleanup() {
894
894
 
895
895
  // src/cli/pnpm11.ts
896
896
  const HOIST_LINE = /^public-hoist-pattern\[\]=(.+)$/;
897
- 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})$`);
898
901
  /**
899
902
  * Pure parser (no I/O): extracts the flagged `pkg@version` tokens from captured
900
- * pnpm output. Order-preserving and de-duplicated. Returns [] for unrelated or
901
- * 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.
902
906
  */
903
907
  function parseReleaseAgeViolations(stdout, stderr) {
904
- const tokens = `${stdout}\n${stderr}`.split("\n").map((line) => RELEASE_AGE_LINE.exec(line.trim())?.[1]).filter((token) => token !== void 0);
905
- 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);
906
920
  }
907
921
  /** Pure helper: the suggested pnpm-workspace.yaml exclude line for a flagged token. */
908
922
  function buildReleaseAgeExcludeLine(pkgVersion) {
909
923
  return `minimumReleaseAgeExclude:\n${renderExcludeEntry(releaseAgeExcludeEntry(pkgVersion))}`;
910
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"];
911
927
  /**
912
- * Default probe: runs `pnpm install --resolution-only`, which re-runs resolution
913
- * (re-applying minimumReleaseAge) and captures its output. If pnpm is not on PATH
914
- * 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.
915
933
  *
916
- * SNAPSHOT/RESTORE: `--resolution-only` is NOT non-mutating in general. When the
917
- * committed `pnpm-lock.yaml` is stale or doesn't match resolution, pnpm REWRITES
918
- * it to match (it only avoids writing when it ABORTS on a violation). A read-only
919
- * diagnostic must never touch a consumer's lockfile, so we snapshot the exact
920
- * bytes before spawning and restore them in a `finally` — putting the file back
921
- * exactly as found (or removing one the probe created where none existed).
922
- * `--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).
923
940
  */
924
941
  const defaultReleaseAgeProbe = (dir) => {
925
- const lockPath = join(dir, "pnpm-lock.yaml");
926
- const lockExisted = existsSync(lockPath);
927
- const originalLock = lockExisted ? readFileSync(lockPath) : void 0;
942
+ const snapshots = probeWrittenFiles(dir).map(snapshotFile);
928
943
  try {
929
- const result = spawnSync("pnpm", ["install", "--resolution-only"], {
944
+ const result = spawnSync("pnpm", [...RELEASE_AGE_PROBE_ARGS], {
930
945
  cwd: dir,
931
946
  encoding: "utf-8",
932
947
  env: process.env
@@ -939,13 +954,46 @@ const defaultReleaseAgeProbe = (dir) => {
939
954
  return {
940
955
  stdout: result.stdout ?? "",
941
956
  stderr: result.stderr ?? "",
942
- status: result.status ?? 1
957
+ status: result.status ?? 1,
958
+ autoExcluded: snapshots.flatMap(addedExcludeEntries)
943
959
  };
944
960
  } finally {
945
- if (lockExisted && originalLock !== void 0) writeFileSync(lockPath, originalLock);
946
- else if (!lockExisted && existsSync(lockPath)) rmSync(lockPath, { force: true });
961
+ snapshots.forEach(restoreFile);
947
962
  }
948
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
+ }
949
997
  /**
950
998
  * Curated set of packages whose build scripts are known to be unnecessary under
951
999
  * pnpm 11's strictDepBuilds. Extend ONLY after verification — this list drives
@@ -996,11 +1044,34 @@ function detectAllowBuildsIssues(dir) {
996
1044
  }));
997
1045
  }
998
1046
  function detectReleaseAgeIssues(dir, probe) {
999
- const { stdout, stderr } = probe(dir);
1000
- 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) => ({
1001
1051
  severity: "warning",
1002
1052
  message: `${pkgVersion} is newer than the pnpm minimumReleaseAge cutoff — add to pnpm-workspace.yaml to allow it:\n ${buildReleaseAgeExcludeLine(pkgVersion)}`
1003
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}`);
1004
1075
  }
1005
1076
  function readHoistPatterns(npmrc) {
1006
1077
  return npmrc.split("\n").map((line) => HOIST_LINE.exec(line.trim())).filter((m) => m !== null).map((m) => m[1]);
@@ -1096,11 +1167,14 @@ function renderExcludeEntry(entry) {
1096
1167
  }
1097
1168
  /**
1098
1169
  * Pure parser: returns the set of entries already listed under
1099
- * `minimumReleaseAgeExclude:`, normalized (quotes stripped). Same manual
1100
- * 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.
1101
1174
  */
1102
1175
  function readReleaseAgeExcludeEntries(yaml) {
1103
- 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));
1104
1178
  }
1105
1179
  /**
1106
1180
  * Insert YAML list entries (already rendered as ` - x` lines) into an existing
@@ -1275,8 +1349,26 @@ function migratePackageJsonPnpm(dir, ws) {
1275
1349
  * sourceMutation is needed — it joins the same single ws write.
1276
1350
  */
1277
1351
  function migrateReleaseAgeExcludes(dir, ws, probe) {
1278
- const { stdout, stderr } = probe(dir);
1279
- 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
+ }));
1280
1372
  const covered = readReleaseAgeExcludeEntries(ws).map(releaseAgeExcludeEntry);
1281
1373
  const newEntries = List(violations.map(releaseAgeExcludeEntry)).filter((entry) => !covered.has(entry)).distinct().toArray();
1282
1374
  if (newEntries.length === 0) return noChange(ws);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-builds",
3
- "version": "4.0.1",
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
  }