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.
- package/dist/cli.js +121 -29
- 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
|
|
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
|
|
901
|
-
* empty output so callers never emit
|
|
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
|
|
905
|
-
|
|
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 --
|
|
913
|
-
* (re-applying minimumReleaseAge)
|
|
914
|
-
*
|
|
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:
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
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
|
|
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", [
|
|
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
|
-
|
|
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
|
|
1000
|
-
|
|
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).
|
|
1100
|
-
*
|
|
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
|
-
|
|
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
|
|
1279
|
-
const
|
|
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.
|
|
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@
|
|
118
|
+
"packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61"
|
|
119
119
|
}
|