@bobfrankston/npmglobalize 1.0.229 → 1.0.231

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 (5) hide show
  1. package/README.md +19 -0
  2. package/cli.js +20 -3
  3. package/lib.d.ts +55 -2
  4. package/lib.js +268 -19
  5. package/package.json +1 -1
package/README.md CHANGED
@@ -586,6 +586,25 @@ Workspace mode is auto-detected when run from a root with `"private": true` and
586
586
  migration automatically when a build hits a TS7 deprecation
587
587
  error, so day-to-day you never need this flag.
588
588
  npmglobalize <path> -tsfix
589
+ Relative imports that NodeNext rejects for lacking a file
590
+ extension (TS2835) are fixed too, in the release flow and
591
+ under -tsfix alike: the extension tsc itself suggests is
592
+ written into the import ("./a" -> "./a.js", "./utils" ->
593
+ "./utils/index.js"), driven only by tsc's diagnostics, never
594
+ by a regex over source, so comments, template strings and
595
+ bare package specifiers are never touched. A noEmit project
596
+ that Node runs directly gets ".ts" instead and
597
+ allowImportingTsExtensions is switched on, since Node's type
598
+ stripping does not map "./a.js" to a.ts. A suggestion that is
599
+ not just an added extension is left alone and reported. Each
600
+ rewritten import is printed and counted in the issues summary
601
+ so the diff can be reviewed. -tsfix type-checks each package
602
+ with tsc --noEmit to get those diagnostics (it still emits
603
+ nothing, commits nothing, publishes nothing). If a build
604
+ still fails after the import fixes, the tsconfig is put back
605
+ so the build keeps working, the import edits stay (a named
606
+ file is right under the old resolver too), and every
607
+ remaining error is listed for fixing by hand. (2026-09-14)
589
608
  -show Show package.json dependency changes
590
609
  -package, -pkg Update package.json scripts to use npmglobalize (see below)
591
610
  -h, -help Show help
package/cli.js CHANGED
@@ -592,7 +592,7 @@ export async function main() {
592
592
  if (cliOptions.ts7Fix) {
593
593
  console.log('');
594
594
  console.log(colors.accent('━━━ TypeScript 7 tsconfig fix ━━━'));
595
- const { changedDirs, remaining } = fixTs7Deprecations(cwd);
595
+ const { changedDirs, remaining, importFixes, typeErrors } = await fixTs7Deprecations(cwd);
596
596
  if (changedDirs.length === 0) {
597
597
  console.log(colors.success(' ✓ Nothing to fix — no removed-in-TS7 compilerOptions in writable tsconfigs.'));
598
598
  }
@@ -600,7 +600,24 @@ export async function main() {
600
600
  console.log(colors.success(` ✓ Patched tsconfig(s) in ${changedDirs.length} package(s):`));
601
601
  for (const dir of changedDirs)
602
602
  console.log(` ${dir}`);
603
- console.log(colors.muted(' Rebuild to validate — NodeNext resolution may surface real import errors the old resolver hid.'));
603
+ }
604
+ // 2026-09-14 — Claude Code (Fable 5.1): -tsfix now also type-checks each package
605
+ // and adds the extension NodeNext requires on relative imports (see
606
+ // fixImportExtensions in lib.ts). What still fails is listed, per package.
607
+ const totalImports = [...importFixes.values()].reduce((a, b) => a + b, 0);
608
+ if (totalImports > 0) {
609
+ console.log(colors.success(` ✓ Added file extensions to ${totalImports} relative import(s) in ${importFixes.size} package(s) — review the diff.`));
610
+ }
611
+ if (typeErrors.size > 0) {
612
+ console.log(colors.warn(` ${typeErrors.size} package(s) still fail to type-check under the migrated settings:`));
613
+ for (const [dir, errs] of typeErrors) {
614
+ console.log(colors.warn(` ${dir}`));
615
+ for (const e of errs)
616
+ console.log(colors.warn(` ${e}`));
617
+ }
618
+ }
619
+ else if (changedDirs.length > 0 || totalImports > 0) {
620
+ console.log(colors.success(' ✓ Every package type-checks under the migrated settings.'));
604
621
  }
605
622
  if (remaining.length > 0) {
606
623
  console.log(colors.warn(` ${remaining.length} item(s) could not be auto-fixed:`));
@@ -611,7 +628,7 @@ export async function main() {
611
628
  }
612
629
  console.log('');
613
630
  printBuildSummary();
614
- process.exit(remaining.length > 0 ? 1 : 0);
631
+ process.exit(remaining.length > 0 || typeErrors.size > 0 ? 1 : 0);
615
632
  }
616
633
  // Build file: deps topologically, then the target itself.
617
634
  // Ensures consumers' tsc sees up-to-date `.d.ts` from sibling checkouts
package/lib.d.ts CHANGED
@@ -25,6 +25,57 @@ export declare function clearBuildIssues(): void;
25
25
  /** Extract the first TypeScript error line from build output for the summary.
26
26
  * Returns a short string like "file.ts(42,5): error TS2339: Property 'foo' ..." */
27
27
  export declare function extractFirstTscError(output: string): string | null;
28
+ /** Every TypeScript error line in build output — `file.ts(line,col): error TSnnnn: message`
29
+ * — in order, deduplicated, untruncated. For reports where the reader will act on each
30
+ * line (the imports a nodenext migration surfaces), as opposed to the one-line summary
31
+ * extractFirstTscError gives. (2026-09-14) */
32
+ export declare function extractTscErrors(output: string): string[];
33
+ /** One relative import rewritten by fixImportExtensions. */
34
+ export interface ImportExtensionFix {
35
+ file: string; /** Path as tsc printed it (relative to the build's cwd) */
36
+ line: number;
37
+ col: number;
38
+ from: string; /** Specifier as it was, e.g. "./a" */
39
+ to: string; /** Specifier as written, e.g. "./a.js" */
40
+ }
41
+ /** A TS2835 diagnostic fixImportExtensions left alone, and why. */
42
+ export interface ImportExtensionSkip {
43
+ file: string;
44
+ line: number;
45
+ col: number;
46
+ reason: string;
47
+ }
48
+ /** Add the file extension NodeNext requires on relative imports, driven ONLY by tsc's own
49
+ * TS2835 diagnostics in `output` ("Relative import paths need explicit file extensions …
50
+ * Did you mean './a.js'?"). Each diagnostic carries the file, the exact position of the
51
+ * specifier's opening quote, and tsc's suggested specifier, which already accounts for
52
+ * directory imports ('./utils' → './utils/index.js') and other source kinds ('.mts' →
53
+ * '.mjs'). No regex over source, so comments, template strings, dynamic imports and bare
54
+ * package specifiers are never touched.
55
+ *
56
+ * TS2834 is the same complaint without a suggestion — TypeScript 6 gives it for a
57
+ * directory import ('./utils' → utils/index.ts). For those the suggestion is derived from
58
+ * disk, relative to the importing file, and only when exactly one answer exists:
59
+ * utils.ts/.tsx/.d.ts/.js → './utils.js', .mts → '.mjs', .cts → '.cjs', a directory
60
+ * with an index file → './utils/index.js' (or .mjs/.cjs). Nothing found, or both a
61
+ * file and a directory, is skipped and reported.
62
+ *
63
+ * A fix is applied only when the suggestion is the current specifier plus an extension,
64
+ * or plus '/index' and an extension; anything else is skipped and reported. A noEmit
65
+ * project run directly by Node gets the '.ts' family instead of '.js': Node's type
66
+ * stripping does not map './a.js' to a.ts, so tsc's suggestion would break the run.
67
+ *
68
+ * Edits are applied per file from the bottom up so earlier positions stay valid.
69
+ * 2026-09-14 19:00 EDT — Claude Code (Fable 5.1), at Bob's direction: "Might as well
70
+ * fix the imports too since I just use modules and legacy problems should be rare or
71
+ * nonexistent." Only relative specifiers are ever in scope (Bob: "I presume this is
72
+ * only for relative imports rather than libraries"). */
73
+ export declare function fixImportExtensions(cwd: string, output: string, opts?: {
74
+ useTsExtensions?: boolean;
75
+ }): {
76
+ fixed: ImportExtensionFix[];
77
+ skipped: ImportExtensionSkip[];
78
+ };
28
79
  /** A TS7016 — "Could not find a declaration file for module 'X'" — that tsc blames
29
80
  * on the file being compiled is frequently not that file's fault: the copy of X in
30
81
  * node_modules carries no `.d.ts` whatsoever. That happens when X was published at
@@ -531,10 +582,12 @@ export declare function reportTs7Deprecations(cwd: string): Ts7Finding[];
531
582
  * the next real TS7-removal error). Returns the dirs whose tsconfigs changed
532
583
  * plus the findings that remain (e.g. options inherited from read-only
533
584
  * node_modules bases). */
534
- export declare function fixTs7Deprecations(cwd: string): {
585
+ export declare function fixTs7Deprecations(cwd: string): Promise<{
535
586
  changedDirs: string[];
536
587
  remaining: Ts7Finding[];
537
- };
588
+ importFixes: Map<string, number>;
589
+ typeErrors: Map<string, string[]>;
590
+ }>;
538
591
  /** Ensure the workspace-root `node_modules/` is in sync with every member's
539
592
  * declared deps. Workspaces hoist deps to the root, so a dep added to any
540
593
  * member `package.json` without a follow-up `npm install` at the root leaves
package/lib.js CHANGED
@@ -78,6 +78,197 @@ export function extractFirstTscError(output) {
78
78
  }
79
79
  return null;
80
80
  }
81
+ /** Every TypeScript error line in build output — `file.ts(line,col): error TSnnnn: message`
82
+ * — in order, deduplicated, untruncated. For reports where the reader will act on each
83
+ * line (the imports a nodenext migration surfaces), as opposed to the one-line summary
84
+ * extractFirstTscError gives. (2026-09-14) */
85
+ export function extractTscErrors(output) {
86
+ if (!output)
87
+ return [];
88
+ const seen = new Set();
89
+ const lines = [];
90
+ for (const m of output.matchAll(/^(.+?\(\d+,\d+\): error TS\d+: .+)$/gm)) {
91
+ const line = m[1].trimEnd();
92
+ if (seen.has(line))
93
+ continue;
94
+ seen.add(line);
95
+ lines.push(line);
96
+ }
97
+ return lines;
98
+ }
99
+ /** Add the file extension NodeNext requires on relative imports, driven ONLY by tsc's own
100
+ * TS2835 diagnostics in `output` ("Relative import paths need explicit file extensions …
101
+ * Did you mean './a.js'?"). Each diagnostic carries the file, the exact position of the
102
+ * specifier's opening quote, and tsc's suggested specifier, which already accounts for
103
+ * directory imports ('./utils' → './utils/index.js') and other source kinds ('.mts' →
104
+ * '.mjs'). No regex over source, so comments, template strings, dynamic imports and bare
105
+ * package specifiers are never touched.
106
+ *
107
+ * TS2834 is the same complaint without a suggestion — TypeScript 6 gives it for a
108
+ * directory import ('./utils' → utils/index.ts). For those the suggestion is derived from
109
+ * disk, relative to the importing file, and only when exactly one answer exists:
110
+ * utils.ts/.tsx/.d.ts/.js → './utils.js', .mts → '.mjs', .cts → '.cjs', a directory
111
+ * with an index file → './utils/index.js' (or .mjs/.cjs). Nothing found, or both a
112
+ * file and a directory, is skipped and reported.
113
+ *
114
+ * A fix is applied only when the suggestion is the current specifier plus an extension,
115
+ * or plus '/index' and an extension; anything else is skipped and reported. A noEmit
116
+ * project run directly by Node gets the '.ts' family instead of '.js': Node's type
117
+ * stripping does not map './a.js' to a.ts, so tsc's suggestion would break the run.
118
+ *
119
+ * Edits are applied per file from the bottom up so earlier positions stay valid.
120
+ * 2026-09-14 19:00 EDT — Claude Code (Fable 5.1), at Bob's direction: "Might as well
121
+ * fix the imports too since I just use modules and legacy problems should be rare or
122
+ * nonexistent." Only relative specifiers are ever in scope (Bob: "I presume this is
123
+ * only for relative imports rather than libraries"). */
124
+ export function fixImportExtensions(cwd, output, opts = {}) {
125
+ const fixed = [];
126
+ const skipped = [];
127
+ if (!output)
128
+ return { fixed, skipped };
129
+ // file(line,col): error TS2835: Relative import paths need explicit file extensions … Did you mean './a.js'?
130
+ // file(line,col): error TS2834: Relative import paths need explicit file extensions … Consider adding an extension to the import path.
131
+ const re = /^(.+?)\((\d+),(\d+)\): error TS283[45]: [^\n]*?(?:Did you mean '([^']+)'\?)?\s*$/gm;
132
+ const byFile = new Map();
133
+ for (const m of output.matchAll(re)) {
134
+ const list = byFile.get(m[1]) ?? [];
135
+ list.push({ line: Number(m[2]), col: Number(m[3]), suggested: m[4] || '' });
136
+ byFile.set(m[1], list);
137
+ }
138
+ const toTs = (spec) => spec.replace(/\.js$/, '.ts').replace(/\.mjs$/, '.mts').replace(/\.cjs$/, '.cts');
139
+ /** What `from` (as written in `importer`) should become, decided from what is on disk.
140
+ * Returns the single answer, or an explanation of why there is none. */
141
+ const suggestFromDisk = (importer, from) => {
142
+ const target = path.resolve(path.dirname(importer), from);
143
+ const base = from.replace(/\/$/, '');
144
+ // [file on disk, specifier to write]
145
+ const candidates = [
146
+ [`${target}.ts`, `${base}.js`], [`${target}.tsx`, `${base}.js`], [`${target}.d.ts`, `${base}.js`], [`${target}.js`, `${base}.js`],
147
+ [`${target}.mts`, `${base}.mjs`], [`${target}.mjs`, `${base}.mjs`],
148
+ [`${target}.cts`, `${base}.cjs`], [`${target}.cjs`, `${base}.cjs`],
149
+ [path.join(target, 'index.ts'), `${base}/index.js`], [path.join(target, 'index.tsx'), `${base}/index.js`],
150
+ [path.join(target, 'index.d.ts'), `${base}/index.js`], [path.join(target, 'index.js'), `${base}/index.js`],
151
+ [path.join(target, 'index.mts'), `${base}/index.mjs`], [path.join(target, 'index.mjs'), `${base}/index.mjs`],
152
+ [path.join(target, 'index.cts'), `${base}/index.cjs`], [path.join(target, 'index.cjs'), `${base}/index.cjs`],
153
+ ];
154
+ const found = new Set(candidates.filter(([file]) => fs.existsSync(file)).map(([, spec]) => spec));
155
+ if (found.size === 1)
156
+ return { suggested: [...found][0] };
157
+ if (found.size === 0)
158
+ return { reason: `tsc gave no suggestion and nothing on disk matches "${from}" (looked for ${path.basename(target)}.ts/.tsx/.mts/.cts/.js and ${path.basename(target)}/index.*)` };
159
+ return { reason: `tsc gave no suggestion and "${from}" is ambiguous on disk: ${[...found].join(' or ')}` };
160
+ };
161
+ for (const [file, diags] of byFile) {
162
+ const filePath = path.resolve(cwd, file);
163
+ let text;
164
+ try {
165
+ text = fs.readFileSync(filePath, 'utf-8');
166
+ }
167
+ catch (error) {
168
+ for (const d of diags)
169
+ skipped.push({ file, line: d.line, col: d.col, reason: `cannot read ${filePath}: ${error.message}` });
170
+ continue;
171
+ }
172
+ const lines = text.split(/(?<=\n)/); // keep each line's own EOL
173
+ // Bottom-up, and right-to-left within a line, so untouched positions stay valid.
174
+ diags.sort((a, b) => b.line - a.line || b.col - a.col);
175
+ const seen = new Set();
176
+ let changed = false;
177
+ for (const d of diags) {
178
+ const key = `${d.line}:${d.col}`;
179
+ if (seen.has(key))
180
+ continue; // tsc can report the same position twice (e.g. import + re-export builds)
181
+ seen.add(key);
182
+ const lineText = lines[d.line - 1];
183
+ const quote = lineText?.[d.col - 1];
184
+ if (quote !== '"' && quote !== "'") {
185
+ skipped.push({ file, line: d.line, col: d.col, reason: 'no string literal at the reported position (file changed since the build?)' });
186
+ continue;
187
+ }
188
+ const close = lineText.indexOf(quote, d.col);
189
+ if (close < 0) {
190
+ skipped.push({ file, line: d.line, col: d.col, reason: 'unterminated string at the reported position' });
191
+ continue;
192
+ }
193
+ const from = lineText.slice(d.col, close);
194
+ if (!from.startsWith('./') && !from.startsWith('../')) {
195
+ skipped.push({ file, line: d.line, col: d.col, reason: `"${from}" is not a relative specifier` });
196
+ continue;
197
+ }
198
+ if (!d.suggested) {
199
+ const derived = suggestFromDisk(filePath, from);
200
+ if ('reason' in derived) {
201
+ skipped.push({ file, line: d.line, col: d.col, reason: derived.reason });
202
+ continue;
203
+ }
204
+ d.suggested = derived.suggested;
205
+ }
206
+ const base = from.replace(/\/$/, '');
207
+ const isPlainAppend = /^\.(js|mjs|cjs)$/.test(d.suggested.slice(from.length)) && d.suggested.startsWith(from);
208
+ const isIndexAppend = d.suggested.startsWith(base + '/index.') && /^\.(js|mjs|cjs)$/.test(d.suggested.slice(base.length + '/index'.length));
209
+ if (!isPlainAppend && !isIndexAppend) {
210
+ skipped.push({ file, line: d.line, col: d.col, reason: `tsc suggests "${d.suggested}" for "${from}", which is not just an added extension — left for a human` });
211
+ continue;
212
+ }
213
+ const to = opts.useTsExtensions ? toTs(d.suggested) : d.suggested;
214
+ lines[d.line - 1] = lineText.slice(0, d.col) + to + lineText.slice(close);
215
+ fixed.push({ file, line: d.line, col: d.col, from, to });
216
+ changed = true;
217
+ }
218
+ if (changed) {
219
+ try {
220
+ fs.writeFileSync(filePath, lines.join(''));
221
+ }
222
+ catch (error) {
223
+ // Nothing on disk changed for this file, so its fixes did not happen: move them to skipped.
224
+ for (let i = fixed.length - 1; i >= 0; i--) {
225
+ if (fixed[i].file !== file)
226
+ continue;
227
+ skipped.push({ file, line: fixed[i].line, col: fixed[i].col, reason: `cannot write ${filePath}: ${error.message}` });
228
+ fixed.splice(i, 1);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ fixed.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col);
234
+ return { fixed, skipped };
235
+ }
236
+ /** Run fixImportExtensions for `dir` against a build's output and print what it did.
237
+ * For a noEmit project the '.ts' extension is written and `allowImportingTsExtensions`
238
+ * is switched on in the most-leaf writable tsconfig, since tsc rejects '.ts' specifiers
239
+ * without it (TS5097). Returns the number of imports rewritten. */
240
+ function applyImportExtensionFixes(dir, output, label) {
241
+ const useTs = isNoEmitProject(dir);
242
+ const { fixed, skipped } = fixImportExtensions(dir, output, { useTsExtensions: useTs });
243
+ for (const f of fixed)
244
+ console.log(colors.cyan(` Fixed import in ${f.file}(${f.line},${f.col}): "${f.from}" → "${f.to}"`));
245
+ for (const s of skipped)
246
+ console.log(colors.yellow(` Left import in ${s.file}(${s.line},${s.col}): ${s.reason}`));
247
+ if (fixed.length && useTs) {
248
+ const tsconfigPath = path.join(dir, 'tsconfig.json');
249
+ const { chain } = resolveTsconfigChain(tsconfigPath);
250
+ const already = effectiveCompilerOption(chain, 'allowImportingTsExtensions')?.value === true;
251
+ const target = chain.find(c => c.writable);
252
+ if (!already && target) {
253
+ try {
254
+ const text = fs.readFileSync(target.path, 'utf-8');
255
+ const patched = upsertCompilerOption(text, 'allowImportingTsExtensions', 'true');
256
+ if (patched && patched !== text) {
257
+ fs.writeFileSync(target.path, patched);
258
+ console.log(colors.cyan(` Set allowImportingTsExtensions: true in ${path.relative(dir, target.path) || 'tsconfig.json'} (noEmit project — imports name the .ts files)`));
259
+ }
260
+ }
261
+ catch (error) {
262
+ console.log(colors.yellow(` Could not set allowImportingTsExtensions in ${target.path}: ${error.message}`));
263
+ }
264
+ }
265
+ }
266
+ if (fixed.length) {
267
+ const files = new Set(fixed.map(f => f.file)).size;
268
+ recordBuildIssue(label, 'warning', `Added the ${useTs ? '.ts' : '.js'} extension to ${fixed.length} relative import(s) in ${files} file(s) for NodeNext — review the diff.`);
269
+ }
270
+ return fixed.length;
271
+ }
81
272
  /** Does `dir` contain any `.d.ts` at all? Bounded, and never descends into a
82
273
  * nested `node_modules` — we are asking about this package's own output. */
83
274
  function hasDeclarationFiles(dir, depth = 3) {
@@ -3729,6 +3920,21 @@ export async function buildProject(cwd, opts = {}) {
3729
3920
  buildResult = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
3730
3921
  }
3731
3922
  }
3923
+ // NodeNext surfaced relative imports without extensions (TS2835). Add the
3924
+ // extension tsc suggests and rebuild — two passes at most, since one pass
3925
+ // handles every diagnostic tsc reported and a second only catches what the
3926
+ // first pass's edits uncovered. Kept ahead of the revert below on purpose:
3927
+ // an import that names its file is right under the old resolver too, so
3928
+ // even a build that still fails afterwards keeps these edits.
3929
+ // 2026-09-14 19:00 EDT — Claude Code (Fable 5.1), at Bob's direction.
3930
+ for (let pass = 0; pass < 2 && !buildResult.success; pass++) {
3931
+ const out = (buildResult.stderr || '') + (buildResult.output || '');
3932
+ if (!/error TS283[45]\b/.test(out))
3933
+ break;
3934
+ if (!applyImportExtensionFixes(cwd, out, pkg.name || path.basename(cwd)))
3935
+ break;
3936
+ buildResult = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
3937
+ }
3732
3938
  // "Try changing the 'lib' compiler option to 'es2023' or later" — a tsconfig
3733
3939
  // pinned below the standard-library APIs the code actually uses. Raise it and
3734
3940
  // rebuild. Looped because one level can uncover the need for a higher one,
@@ -3753,13 +3959,23 @@ export async function buildProject(cwd, opts = {}) {
3753
3959
  const retry = await runCommandAsync('npm', ['run', 'build'], { cwd, silent: true });
3754
3960
  if (retry.success) {
3755
3961
  dropBuildIssuesFrom(issueMark); // the migration was undone; its warnings no longer apply
3756
- const firstErr = extractFirstTscError(migratedOutput);
3757
3962
  const label = pkg.name || path.basename(cwd);
3758
- console.log(colors.yellow(` Reverted the tsconfig TS7 migration in ${label} it broke the build:`));
3759
- if (firstErr)
3760
- console.log(colors.yellow(` ${firstErr}`));
3761
- console.log(colors.yellow(` Deprecated settings left in place. Fix with: npmglobalize ${cwd} -tsfix`));
3762
- 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.`);
3963
+ // List EVERY error the migrated build produced, not just the first: these
3964
+ // are the imports (usually relative specifiers missing their .js extension)
3965
+ // that have to be fixed by hand before nodenext can stay. The tsconfig is
3966
+ // left on the deprecated settings so the build keeps working meanwhile;
3967
+ // the next build after the imports are fixed migrates again on its own.
3968
+ // 2026-09-14 18:30 EDT — Claude Code (Fable 5.1), at Bob's direction:
3969
+ // "Rather than doing the rewrite of the imports, flag them so I can
3970
+ // manually fix them since I expect most cases have already been fixed."
3971
+ const errs = extractTscErrors(migratedOutput);
3972
+ console.log(colors.yellow(` Reverted the tsconfig TS7 migration in ${label} — under "nodenext" the build fails on ${errs.length || 'these'} error(s):`));
3973
+ for (const e of errs)
3974
+ console.log(colors.yellow(` ${e}`));
3975
+ if (!errs.length)
3976
+ console.log(colors.yellow(` ${extractFirstTscError(migratedOutput) || '(no tsc error line found — rerun with -verbose)'}`));
3977
+ console.log(colors.yellow(` Deprecated settings left in place so the build still works. Fix the imports above; the migration reruns on the next build.`));
3978
+ recordBuildIssue(label, 'warning', `tsconfig still uses settings TypeScript 7 removes: under "nodenext" the build fails on ${errs.length} error(s) (first: ${errs[0] || extractFirstTscError(migratedOutput) || 'see build output'}). Fix the listed imports; the migration reruns on the next build.`);
3763
3979
  buildResult = retry;
3764
3980
  }
3765
3981
  else {
@@ -4003,12 +4219,45 @@ export function reportTs7Deprecations(cwd) {
4003
4219
  * the next real TS7-removal error). Returns the dirs whose tsconfigs changed
4004
4220
  * plus the findings that remain (e.g. options inherited from read-only
4005
4221
  * node_modules bases). */
4006
- export function fixTs7Deprecations(cwd) {
4222
+ export async function fixTs7Deprecations(cwd) {
4007
4223
  const changedDirs = [];
4008
- for (const dir of collectFileDepDirs(cwd)) {
4224
+ const dirs = collectFileDepDirs(cwd);
4225
+ for (const dir of dirs) {
4009
4226
  if (migrateTsconfigDeprecations(dir))
4010
4227
  changedDirs.push(dir);
4011
4228
  }
4229
+ // Then the imports NodeNext rejects. A type-check pass (`tsc --noEmit`, nothing
4230
+ // written by tsc itself) yields the TS2835 diagnostics that drive
4231
+ // fixImportExtensions; a second pass catches what the first one's edits
4232
+ // uncovered, and whatever still fails is handed back for a human.
4233
+ // 2026-09-14 19:00 EDT — Claude Code (Fable 5.1), at Bob's direction.
4234
+ const importFixes = new Map();
4235
+ const typeErrors = new Map();
4236
+ for (const dir of dirs) {
4237
+ if (!fs.existsSync(path.join(dir, 'tsconfig.json')))
4238
+ continue;
4239
+ const label = (() => { try {
4240
+ return readPackageJson(dir).name;
4241
+ }
4242
+ catch {
4243
+ return null;
4244
+ } })() || path.basename(dir);
4245
+ let result = await runCommandAsync('tsc', ['-p', '.', '--noEmit'], { cwd: dir, silent: true });
4246
+ for (let pass = 0; pass < 2 && !result.success; pass++) {
4247
+ const out = (result.stderr || '') + (result.output || '');
4248
+ if (!/error TS283[45]\b/.test(out))
4249
+ break;
4250
+ const fixedCount = applyImportExtensionFixes(dir, out, label);
4251
+ if (!fixedCount)
4252
+ break;
4253
+ importFixes.set(dir, (importFixes.get(dir) ?? 0) + fixedCount);
4254
+ result = await runCommandAsync('tsc', ['-p', '.', '--noEmit'], { cwd: dir, silent: true });
4255
+ }
4256
+ if (!result.success) {
4257
+ const errs = extractTscErrors((result.stderr || '') + (result.output || ''));
4258
+ typeErrors.set(dir, errs.length ? errs : [(result.stderr || result.output || '').trim().split('\n')[0] || 'tsc failed with no output']);
4259
+ }
4260
+ }
4012
4261
  // Drop ignoreDeprecations wherever nothing deprecated remains in that package.
4013
4262
  let remaining = reportTs7Deprecations(cwd);
4014
4263
  const igdDirs = new Set(remaining.filter(f => f.option === 'ignoreDeprecations').map(f => f.dir));
@@ -4040,7 +4289,7 @@ export function fixTs7Deprecations(cwd) {
4040
4289
  }
4041
4290
  if (removedIgd)
4042
4291
  remaining = reportTs7Deprecations(cwd);
4043
- return { changedDirs, remaining };
4292
+ return { changedDirs, remaining, importFixes, typeErrors };
4044
4293
  }
4045
4294
  /** Ensure the workspace-root `node_modules/` is in sync with every member's
4046
4295
  * declared deps. Workspaces hoist deps to the root, so a dep added to any
@@ -4842,7 +5091,9 @@ export function runCommand(cmd, args, options = {}) {
4842
5091
  * child, restore deps, and exit — instead of being queued behind spawnSync. */
4843
5092
  export function runCommandAsync(cmd, args, options = {}) {
4844
5093
  const { silent = false, verbose = false, showCommand = false, cwd } = options;
4845
- const needsShell = cmd === 'npm' || cmd === 'npm.cmd' || cmd === 'gh';
5094
+ // npm, gh and tsc are .cmd shims on Windows, which spawn() cannot find without a shell.
5095
+ // (tsc added 2026-09-14 for the -tsfix import-extension pass.)
5096
+ const needsShell = cmd === 'npm' || cmd === 'npm.cmd' || cmd === 'gh' || cmd === 'tsc';
4846
5097
  if (!silent && (showCommand || verbose)) {
4847
5098
  console.log(colors.cyan(`> ${cmd} ${args.join(' ')}`));
4848
5099
  }
@@ -5755,19 +6006,17 @@ const ALL_GITIGNORE = [...IGNORE_PATTERNS.gitignore.security, ...IGNORE_PATTERNS
5755
6006
  const ALL_NPMIGNORE = [...IGNORE_PATTERNS.npmignore.security, ...IGNORE_PATTERNS.npmignore.recommended];
5756
6007
  /** Patterns that should NOT be in .npmignore for noEmit projects (TS files are the runtime files) */
5757
6008
  const TS_NPMIGNORE_PATTERNS = new Set(['*.ts', '!*.d.ts', '*.map', 'tsconfig.json']);
5758
- /** Check if target project uses noEmit (TS files run directly, no compilation) */
6009
+ /** Check if target project uses noEmit (TS files run directly, no compilation).
6010
+ * Reads the effective value through the `extends` chain, not just the local file.
6011
+ * 2026-09-14 — Claude Code (Fable 5.1): was a bare JSON5 read of tsconfig.json;
6012
+ * now shared with fixImportExtensions, where a wrong answer writes ".js" into a
6013
+ * project Node runs from its .ts and breaks it at runtime. */
5759
6014
  function isNoEmitProject(cwd) {
5760
6015
  const tsconfigPath = path.join(cwd, 'tsconfig.json');
5761
6016
  if (!fs.existsSync(tsconfigPath))
5762
6017
  return false;
5763
- try {
5764
- const content = fs.readFileSync(tsconfigPath, 'utf-8');
5765
- const tsconfig = JSON5.parse(content);
5766
- return tsconfig.compilerOptions?.noEmit === true;
5767
- }
5768
- catch {
5769
- return false;
5770
- }
6018
+ const { chain } = resolveTsconfigChain(tsconfigPath);
6019
+ return effectiveCompilerOption(chain, 'noEmit')?.value === true;
5771
6020
  }
5772
6021
  /** Resolve whether .ts source files should be kept in the published tarball.
5773
6022
  * Explicit allowTs wins; otherwise noEmit projects default to including .ts. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.229",
3
+ "version": "1.0.231",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",