@bobfrankston/npmglobalize 1.0.202 → 1.0.204

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/lib.js +202 -4
  2. package/package.json +1 -1
package/lib.js CHANGED
@@ -42,6 +42,16 @@ const _buildIssues = [];
42
42
  export function recordBuildIssue(module, severity, message) {
43
43
  _buildIssues.push({ module, severity, message });
44
44
  }
45
+ /** How many issues have been recorded so far — pair with `dropBuildIssuesFrom`
46
+ * to un-record issues for an action that was subsequently undone. */
47
+ function markBuildIssues() {
48
+ return _buildIssues.length;
49
+ }
50
+ /** Drop every issue recorded after `mark` (see `markBuildIssues`). */
51
+ function dropBuildIssuesFrom(mark) {
52
+ if (mark >= 0 && mark < _buildIssues.length)
53
+ _buildIssues.length = mark;
54
+ }
45
55
  /** Get all accumulated build issues */
46
56
  export function getBuildIssues() {
47
57
  return _buildIssues;
@@ -2323,8 +2333,12 @@ function ensureTsconfigNodeTypes(cwd) {
2323
2333
  * most-leaf writable config so it wins. This is a *semantic* change: the rebuild
2324
2334
  * may now surface genuine resolution errors — that is the point. Those are real
2325
2335
  * bugs the `node10` resolver was hiding, not deprecations. Returns true if it
2326
- * changed any file. */
2327
- function migrateTsconfigDeprecations(cwd) {
2336
+ * changed any file.
2337
+ *
2338
+ * Pass `snapshot` to capture each touched file's original text (path → text)
2339
+ * before it is rewritten, so the caller can put the tsconfigs back if the
2340
+ * migrated build turns out to be worse than the un-migrated one. */
2341
+ function migrateTsconfigDeprecations(cwd, snapshot) {
2328
2342
  const tsconfigPath = path.join(cwd, 'tsconfig.json');
2329
2343
  if (!fs.existsSync(tsconfigPath))
2330
2344
  return false;
@@ -2358,6 +2372,8 @@ function migrateTsconfigDeprecations(cwd) {
2358
2372
  return false;
2359
2373
  try {
2360
2374
  fs.writeFileSync(filePath, patched);
2375
+ if (snapshot && !snapshot.has(filePath))
2376
+ snapshot.set(filePath, text);
2361
2377
  return true;
2362
2378
  }
2363
2379
  catch (error) {
@@ -2413,6 +2429,131 @@ function migrateTsconfigDeprecations(cwd) {
2413
2429
  }
2414
2430
  return changed;
2415
2431
  }
2432
+ /** Ordering of ES language levels as they appear in `target`/`lib`. Anything
2433
+ * unknown ranks 0 so it never looks newer than what the build asked for. */
2434
+ const ES_LEVEL_RANK = {
2435
+ es3: 3, es5: 5, es6: 6, es2015: 6, es7: 7, es2016: 7, es2017: 8, es2018: 9,
2436
+ es2019: 10, es2020: 11, es2021: 12, es2022: 13, es2023: 14, es2024: 15,
2437
+ es2025: 16, esnext: 99,
2438
+ };
2439
+ const esLevelRank = (value) => ES_LEVEL_RANK[value.toLowerCase().replace(/\.full$/, '')] ?? 0;
2440
+ /** tsc says "Try changing the 'lib' compiler option to 'es2023' or later"
2441
+ * (TS2550/TS2583/TS2584) when the code uses a standard-library API newer than
2442
+ * the configured level. That isn't a deprecation — it's a tsconfig the language
2443
+ * moved past — so raise the level to whatever the build asked for and let it
2444
+ * try again, rather than leaving the user to decode `replaceAll` and `toSorted`
2445
+ * errors by hand.
2446
+ *
2447
+ * We raise `target`: its implied `lib` keeps the DOM declarations a bare
2448
+ * `lib: ["es2023"]` would silently drop. An explicit `lib` overrides `target`
2449
+ * though, so when one is set we raise its `es*` entry in place and keep the
2450
+ * rest (dom, webworker, …). Reads the required level from `buildOutput`;
2451
+ * returns true if it changed any file. */
2452
+ function raiseTsconfigLibLevel(cwd, buildOutput, snapshot) {
2453
+ let required = '';
2454
+ for (const m of buildOutput.matchAll(/'lib' compiler option to '([A-Za-z0-9.]+)'/g)) {
2455
+ if (esLevelRank(m[1]) > esLevelRank(required))
2456
+ required = m[1].toLowerCase();
2457
+ }
2458
+ if (!required)
2459
+ return false;
2460
+ const tsconfigPath = path.join(cwd, 'tsconfig.json');
2461
+ if (!fs.existsSync(tsconfigPath))
2462
+ return false;
2463
+ const { chain } = resolveTsconfigChain(tsconfigPath);
2464
+ if (!chain.length)
2465
+ return false;
2466
+ const mostLeafWritable = chain.find(c => c.writable);
2467
+ if (!mostLeafWritable)
2468
+ return false;
2469
+ const name = (() => { try {
2470
+ return readPackageJson(cwd).name;
2471
+ }
2472
+ catch {
2473
+ return null;
2474
+ } })() || path.basename(cwd);
2475
+ const edit = (filePath, fn) => {
2476
+ let text;
2477
+ try {
2478
+ text = fs.readFileSync(filePath, 'utf-8');
2479
+ }
2480
+ catch {
2481
+ return false;
2482
+ }
2483
+ const patched = fn(text);
2484
+ if (patched == null || patched === text)
2485
+ return false;
2486
+ try {
2487
+ fs.writeFileSync(filePath, patched);
2488
+ if (snapshot && !snapshot.has(filePath))
2489
+ snapshot.set(filePath, text);
2490
+ return true;
2491
+ }
2492
+ catch (error) {
2493
+ console.error(colors.yellow(` Could not write tsconfig patch (${filePath}): ${error.message}`));
2494
+ return false;
2495
+ }
2496
+ };
2497
+ /** The file to edit for an option: where it's defined if that's writable,
2498
+ * else the most-leaf writable config, where an override wins. */
2499
+ const targetFor = (definedIn) => {
2500
+ const def = definedIn ? chain.find(c => c.path === definedIn) : null;
2501
+ return def?.writable ? def : mostLeafWritable;
2502
+ };
2503
+ // An explicit `lib` beats `target`, so it's the one that has to move.
2504
+ const lib = effectiveCompilerOption(chain, 'lib');
2505
+ if (lib && Array.isArray(lib.value)) {
2506
+ const entries = lib.value.filter((e) => typeof e === 'string');
2507
+ if (entries.some(e => esLevelRank(e) >= esLevelRank(required)))
2508
+ return false;
2509
+ const raised = [required, ...entries.filter(e => esLevelRank(e) === 0)];
2510
+ const tgt = targetFor(lib.definedIn);
2511
+ if (!edit(tgt.path, t => upsertCompilerOption(t, 'lib', JSON.stringify(raised).replace(/","/g, '", "'))))
2512
+ return false;
2513
+ console.log(colors.cyan(` Raised lib [${entries.join(', ')}] → [${raised.join(', ')}] in ${path.relative(cwd, tgt.path) || 'tsconfig.json'} (build needs ${required})`));
2514
+ recordBuildIssue(name, 'warning', `Raised "lib" to ${required} — the code uses standard-library APIs newer than the tsconfig allowed.`);
2515
+ return true;
2516
+ }
2517
+ const tg = effectiveCompilerOption(chain, 'target');
2518
+ const current = tg && typeof tg.value === 'string' ? tg.value : 'es5'; // tsc's own default
2519
+ if (esLevelRank(current) >= esLevelRank(required))
2520
+ return false;
2521
+ const tgt = targetFor(tg?.definedIn);
2522
+ if (!edit(tgt.path, t => upsertCompilerOption(t, 'target', `"${required}"`)))
2523
+ return false;
2524
+ console.log(colors.cyan(` Raised target "${current}" → "${required}" in ${path.relative(cwd, tgt.path) || 'tsconfig.json'} (build needs it)`));
2525
+ recordBuildIssue(name, 'warning', `Raised "target" ${current} → ${required} — the code uses standard-library APIs newer than the tsconfig allowed.`);
2526
+ return true;
2527
+ }
2528
+ /** Write back the texts captured in a `migrateTsconfigDeprecations` snapshot
2529
+ * (path → text). Used to undo a migration whose build turned out worse, and to
2530
+ * re-apply it afterwards. Returns false if any write failed — the tree is then
2531
+ * in a mixed state, which the caller reports rather than hides. */
2532
+ function restoreTsconfigTexts(texts) {
2533
+ let ok = true;
2534
+ for (const [filePath, text] of texts) {
2535
+ try {
2536
+ fs.writeFileSync(filePath, text);
2537
+ }
2538
+ catch (error) {
2539
+ ok = false;
2540
+ console.error(colors.yellow(` Could not restore tsconfig (${filePath}): ${error.message}`));
2541
+ }
2542
+ }
2543
+ return ok;
2544
+ }
2545
+ /** Current text of every file in a snapshot, so a migration can be re-applied
2546
+ * after being tried in reverse. */
2547
+ function readTsconfigTexts(paths) {
2548
+ const out = new Map();
2549
+ for (const p of paths) {
2550
+ try {
2551
+ out.set(p, fs.readFileSync(p, 'utf-8'));
2552
+ }
2553
+ catch { /* gone — nothing to re-apply */ }
2554
+ }
2555
+ return out;
2556
+ }
2416
2557
  /** Newest mtime of any regular file under `dir` (recursive). Symlinks/junctions
2417
2558
  * are not followed — the file: dep webs here link back into sibling checkouts. */
2418
2559
  function newestMtimeUnder(dir) {
@@ -2913,15 +3054,29 @@ export async function buildProject(cwd, opts = {}) {
2913
3054
  // with no tsconfig at all, and still need its import map regenerated.
2914
3055
  await ensureImportgenInBuild(cwd);
2915
3056
  let shouldBuild = false;
3057
+ let hasTsconfig = false;
2916
3058
  try {
2917
3059
  const tsconfigPath = path.join(cwd, 'tsconfig.json');
2918
3060
  const content = fs.readFileSync(tsconfigPath, 'utf-8');
2919
3061
  const tsconfig = JSON5.parse(content);
3062
+ hasTsconfig = true;
2920
3063
  shouldBuild = tsconfig.compilerOptions?.noEmit !== true;
2921
3064
  }
2922
3065
  catch {
2923
3066
  // No tsconfig — skip
2924
3067
  }
3068
+ // Fix removed-in-TS7 tsconfig settings up front rather than waiting for a
3069
+ // build to fail on them. A deprecated `moduleResolution` is only a *hard*
3070
+ // error under the tsc that flags it (TS6+, and the editor's bundled
3071
+ // compiler), so a package pinned to an older tsc — or one that never gets
3072
+ // to the build at all (noEmit, no build script, output already fresh) —
3073
+ // would otherwise keep the deprecation until TS7 removes it outright.
3074
+ // Deliberately ahead of every early return below: the tsconfig is worth
3075
+ // fixing whether or not this package builds. Migrating also touches
3076
+ // tsconfig.json, which makes the freshness check correctly read as stale.
3077
+ const tsconfigSnapshot = new Map();
3078
+ const issueMark = markBuildIssues();
3079
+ const migratedUpFront = hasTsconfig && migrateTsconfigDeprecations(cwd, tsconfigSnapshot);
2925
3080
  let earlyPkg;
2926
3081
  try {
2927
3082
  earlyPkg = readPackageJson(cwd);
@@ -2974,7 +3129,9 @@ export async function buildProject(cwd, opts = {}) {
2974
3129
  // freshness — one the user declined to wire in isn't this build's business.
2975
3130
  const finalBuild = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '';
2976
3131
  const wiredSubs = detectSubProjects(cwd).filter(s => scriptBuildsSubProject(finalBuild, s.dir));
2977
- if (!opts.forceBuild && isBuildUpToDate(cwd) && areSubProjectsUpToDate(cwd, wiredSubs)
3132
+ // A migration just changed how modules resolve — never report that as fresh;
3133
+ // the rebuild is what validates it (and what the revert path above needs).
3134
+ if (!opts.forceBuild && !migratedUpFront && isBuildUpToDate(cwd) && areSubProjectsUpToDate(cwd, wiredSubs)
2978
3135
  && (!runsImportgen || isImportMapUpToDate(cwd))) {
2979
3136
  console.log(colors.dim(`– Build up to date (${pkg.name || path.basename(cwd)})`));
2980
3137
  return true;
@@ -2987,10 +3144,51 @@ export async function buildProject(cwd, opts = {}) {
2987
3144
  // We don't silence with ignoreDeprecations — the retry may now surface real
2988
3145
  // resolution errors, which are genuine bugs to fix, not deprecations.
2989
3146
  const out = (buildResult.stderr || '') + (buildResult.output || '');
2990
- if (/error TS510[17]\b/.test(out) && migrateTsconfigDeprecations(cwd)) {
3147
+ if (/error TS510[17]\b/.test(out) && migrateTsconfigDeprecations(cwd, tsconfigSnapshot)) {
2991
3148
  buildResult = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
2992
3149
  }
2993
3150
  }
3151
+ // "Try changing the 'lib' compiler option to 'es2023' or later" — a tsconfig
3152
+ // pinned below the standard-library APIs the code actually uses. Raise it and
3153
+ // rebuild. Looped because one level can uncover the need for a higher one,
3154
+ // and bounded because each pass must raise the level to run again.
3155
+ for (let pass = 0; pass < 3 && !buildResult.success; pass++) {
3156
+ const out = (buildResult.stderr || '') + (buildResult.output || '');
3157
+ if (!raiseTsconfigLibLevel(cwd, out, tsconfigSnapshot))
3158
+ break;
3159
+ buildResult = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
3160
+ }
3161
+ if (!buildResult.success && migratedUpFront && tsconfigSnapshot.size) {
3162
+ // The up-front migration is a semantic change: `nodenext` resolution can
3163
+ // surface real errors (missing file extensions on relative imports) that
3164
+ // `node10` was hiding. Those are genuine bugs — but they are not this
3165
+ // publish's business, and silently breaking a build that worked a moment
3166
+ // ago is worse than leaving the deprecation in place. So put the
3167
+ // tsconfigs back, and if that restores a working build, hand the user
3168
+ // the errors plus the command that migrates for real.
3169
+ const migratedOutput = (buildResult.stderr || '') + (buildResult.output || '');
3170
+ const migratedTexts = readTsconfigTexts(tsconfigSnapshot.keys());
3171
+ if (restoreTsconfigTexts(tsconfigSnapshot)) {
3172
+ const retry = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
3173
+ if (retry.success) {
3174
+ dropBuildIssuesFrom(issueMark); // the migration was undone; its warnings no longer apply
3175
+ const firstErr = extractFirstTscError(migratedOutput);
3176
+ const label = pkg.name || path.basename(cwd);
3177
+ console.log(colors.yellow(` Reverted the tsconfig TS7 migration in ${label} — it broke the build:`));
3178
+ if (firstErr)
3179
+ console.log(colors.yellow(` ${firstErr}`));
3180
+ console.log(colors.yellow(` Deprecated settings left in place. Fix with: npmglobalize ${cwd} -tsfix`));
3181
+ recordBuildIssue(label, 'warning', `tsconfig still uses settings TypeScript 7 removes: migrating to "nodenext" broke the build (${firstErr || 'see build output'}). Run \`npmglobalize ${cwd} -tsfix\` and fix the surfaced imports.`);
3182
+ buildResult = retry;
3183
+ }
3184
+ else {
3185
+ // The build was already failing for its own reasons — the migration
3186
+ // isn't what broke it, so keep the migrated (TS7-ready) tsconfigs
3187
+ // and report the migrated build's failure.
3188
+ restoreTsconfigTexts(migratedTexts);
3189
+ }
3190
+ }
3191
+ }
2994
3192
  if (buildResult.success) {
2995
3193
  console.log(colors.green(`✓ Build succeeded (${pkg.name || path.basename(cwd)})`));
2996
3194
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.202",
3
+ "version": "1.0.204",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",