@bobfrankston/npmglobalize 1.0.204 → 1.0.206

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 (3) hide show
  1. package/lib.d.ts +10 -0
  2. package/lib.js +152 -15
  3. package/package.json +3 -3
package/lib.d.ts CHANGED
@@ -25,6 +25,16 @@ 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
+ /** A TS7016 — "Could not find a declaration file for module 'X'" — that tsc blames
29
+ * on the file being compiled is frequently not that file's fault: the copy of X in
30
+ * node_modules carries no `.d.ts` whatsoever. That happens when X was published at
31
+ * a moment its declaration output was not on disk, so the tarball ships JS only and
32
+ * every consumer resolving X through the registry fails identically. tsc's stock
33
+ * advice — `npm i --save-dev @types/bobfrankston__hlib` — then sends the user after
34
+ * a types package that does not and will never exist, which is worse than no advice.
35
+ * Recognize the shape and say what actually fixes it.
36
+ * Returns one diagnosis line per untyped module; empty when TS7016 has another cause. */
37
+ export declare function diagnoseUntypedDeps(cwd: string, buildOutput: string): string[];
28
38
  /** One package that depends on this one, recorded in this package's
29
39
  * .globalize.json5 when that package publishes. */
30
40
  export interface UpstreamEntry {
package/lib.js CHANGED
@@ -79,6 +79,97 @@ export function extractFirstTscError(output) {
79
79
  }
80
80
  return null;
81
81
  }
82
+ /** Does `dir` contain any `.d.ts` at all? Bounded, and never descends into a
83
+ * nested `node_modules` — we are asking about this package's own output. */
84
+ function hasDeclarationFiles(dir, depth = 3) {
85
+ let entries;
86
+ try {
87
+ entries = fs.readdirSync(dir, { withFileTypes: true });
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ for (const e of entries) {
93
+ if (e.isFile() && e.name.endsWith('.d.ts'))
94
+ return true;
95
+ }
96
+ if (depth <= 0)
97
+ return false;
98
+ for (const e of entries) {
99
+ if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.'))
100
+ continue;
101
+ if (hasDeclarationFiles(path.join(dir, e.name), depth - 1))
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+ /** The spec a consumer declares for `name`. `.dependencies` is checked first
107
+ * because during a publish the live `file:` paths live there while
108
+ * `dependencies` temporarily holds the npm refs that were swapped in. */
109
+ function declaredDepSpec(pkg, name) {
110
+ for (const bucket of ['.dependencies', 'dependencies', 'devDependencies']) {
111
+ const spec = pkg?.[bucket]?.[name];
112
+ if (typeof spec === 'string')
113
+ return spec;
114
+ }
115
+ return '';
116
+ }
117
+ /** A TS7016 — "Could not find a declaration file for module 'X'" — that tsc blames
118
+ * on the file being compiled is frequently not that file's fault: the copy of X in
119
+ * node_modules carries no `.d.ts` whatsoever. That happens when X was published at
120
+ * a moment its declaration output was not on disk, so the tarball ships JS only and
121
+ * every consumer resolving X through the registry fails identically. tsc's stock
122
+ * advice — `npm i --save-dev @types/bobfrankston__hlib` — then sends the user after
123
+ * a types package that does not and will never exist, which is worse than no advice.
124
+ * Recognize the shape and say what actually fixes it.
125
+ * Returns one diagnosis line per untyped module; empty when TS7016 has another cause. */
126
+ export function diagnoseUntypedDeps(cwd, buildOutput) {
127
+ if (!buildOutput)
128
+ return [];
129
+ const named = new Set();
130
+ const re = /error TS7016: Could not find a declaration file for module '([^']+)'/g;
131
+ for (let m = re.exec(buildOutput); m; m = re.exec(buildOutput))
132
+ named.add(m[1]);
133
+ if (!named.size)
134
+ return [];
135
+ let consumer = {};
136
+ try {
137
+ consumer = readPackageJson(cwd);
138
+ }
139
+ catch { /* an unreadable consumer still leaves the dep diagnosable */ }
140
+ const scopeOf = (n) => n.startsWith('@') && n.includes('/') ? n.slice(0, n.indexOf('/')) : '';
141
+ const lines = [];
142
+ for (const name of named) {
143
+ if (name.startsWith('.'))
144
+ continue; // a relative import — a code bug, not a packaging one
145
+ const installed = path.join(cwd, 'node_modules', ...name.split('/'));
146
+ if (!fs.existsSync(path.join(installed, 'package.json')))
147
+ continue;
148
+ if (hasDeclarationFiles(installed))
149
+ continue; // declarations are present; TS7016 came from something else
150
+ let version = '';
151
+ try {
152
+ version = readPackageJson(installed).version || '';
153
+ }
154
+ catch { /* version is decoration here */ }
155
+ const at = version ? `@${version}` : '';
156
+ // A `file:` spec points at live source we can look at directly, which tells
157
+ // us whether the missing declarations are a build problem or a stale install.
158
+ const spec = declaredDepSpec(consumer, name);
159
+ const source = spec.startsWith('file:') ? path.resolve(cwd, spec.slice('file:'.length)) : '';
160
+ if (source && fs.existsSync(source)) {
161
+ lines.push(hasDeclarationFiles(source)
162
+ ? `${name} resolves to a copy in node_modules with no .d.ts, but its source at ${source} has them — the install is stale. Run \`npm install\` in ${cwd}.`
163
+ : `${name}'s source at ${source} emits no .d.ts — set "declaration": true in its tsconfig and rebuild it.`);
164
+ continue;
165
+ }
166
+ const ownScope = scopeOf(name) && scopeOf(name) === scopeOf(consumer?.name || '');
167
+ lines.push(ownScope
168
+ ? `${name}${at} was published with no .d.ts in the tarball, so every consumer that installs it from npm fails this way — it is not a problem in ${consumer?.name || 'this package'}. Rebuild and republish ${name} (\`npmglobalize\` in its source directory), or depend on its source with a file: path. Ignore tsc's @types/… suggestion; no such package exists.`
169
+ : `${name}${at} ships no type declarations. Install its @types package if one exists, or add a .d.ts declaring the module.`);
170
+ }
171
+ return lines;
172
+ }
82
173
  /**
83
174
  * Remove 'nul' files from a directory tree (Windows reserved name issue).
84
175
  * These files break git and npm on Windows. Uses \\?\ prefix to bypass name validation.
@@ -2383,23 +2474,43 @@ function migrateTsconfigDeprecations(cwd, snapshot) {
2383
2474
  };
2384
2475
  const note = (msg) => console.log(colors.cyan(` ${msg}`));
2385
2476
  let changed = false;
2386
- // moduleResolution: node/node10/classic → nodenext (+ align module).
2477
+ // moduleResolution: node/node10/classic → NodeNext (+ align module).
2387
2478
  const mr = effectiveCompilerOption(chain, 'moduleResolution');
2388
2479
  if (mr && typeof mr.value === 'string' && DEPRECATED_MODULE_RESOLUTION.has(mr.value.toLowerCase())) {
2389
2480
  const tgt = targetFor(mr.definedIn);
2390
- if (tgt && edit(tgt.path, t => upsertCompilerOption(t, 'moduleResolution', '"nodenext"'))) {
2481
+ if (tgt && edit(tgt.path, t => upsertCompilerOption(t, 'moduleResolution', `"${CANONICAL_NODENEXT}"`))) {
2391
2482
  changed = true;
2392
- note(`Migrated moduleResolution "${mr.value}" → "nodenext" in ${path.relative(cwd, tgt.path) || 'tsconfig.json'}`);
2483
+ note(`Migrated moduleResolution "${mr.value}" → "${CANONICAL_NODENEXT}" in ${path.relative(cwd, tgt.path) || 'tsconfig.json'}`);
2393
2484
  // nodenext resolution requires a matching module setting.
2394
2485
  const mod = effectiveCompilerOption(chain, 'module');
2395
2486
  const modOk = mod && typeof mod.value === 'string' && /^(node16|nodenext|preserve)$/i.test(mod.value);
2396
2487
  if (!modOk) {
2397
2488
  const modTgt = mod ? targetFor(mod.definedIn) : mostLeafWritable;
2398
- if (modTgt && edit(modTgt.path, t => upsertCompilerOption(t, 'module', '"nodenext"'))) {
2399
- note(`Aligned module → "nodenext" (required by moduleResolution "nodenext")`);
2489
+ if (modTgt && edit(modTgt.path, t => upsertCompilerOption(t, 'module', `"${CANONICAL_NODENEXT}"`))) {
2490
+ note(`Aligned module → "${CANONICAL_NODENEXT}" (required by moduleResolution "${CANONICAL_NODENEXT}")`);
2400
2491
  }
2401
2492
  }
2402
- recordBuildIssue(name, 'warning', `Migrated moduleResolution "${mr.value}" → "nodenext". Verify relative imports resolve (NodeNext requires explicit file extensions on relative specifiers).`);
2493
+ recordBuildIssue(name, 'warning', `Migrated moduleResolution "${mr.value}" → "${CANONICAL_NODENEXT}". Verify relative imports resolve (NodeNext requires explicit file extensions on relative specifiers).`);
2494
+ }
2495
+ }
2496
+ // Canonical casing for values we write ourselves. TypeScript reads these
2497
+ // case-insensitively, but the JSON schema behind editor linting (webhint,
2498
+ // "Microsoft Edge Tools") only accepts the documented spelling, so an
2499
+ // all-lowercase "nodenext" we wrote earlier shows up as an error in the
2500
+ // editor even though the build is fine.
2501
+ for (const key of ['module', 'moduleResolution']) {
2502
+ const opt = effectiveCompilerOption(chain, key);
2503
+ if (!opt || typeof opt.value !== 'string')
2504
+ continue;
2505
+ const canonical = CANONICAL_CASING[opt.value.toLowerCase()];
2506
+ if (!canonical || canonical === opt.value)
2507
+ continue;
2508
+ const def = chain.find(c => c.path === opt.definedIn);
2509
+ if (!def?.writable)
2510
+ continue; // an override would be noise, not a fix
2511
+ if (edit(def.path, t => upsertCompilerOption(t, key, `"${canonical}"`))) {
2512
+ changed = true;
2513
+ note(`Normalized ${key} "${opt.value}" → "${canonical}" in ${path.relative(cwd, def.path) || 'tsconfig.json'}`);
2403
2514
  }
2404
2515
  }
2405
2516
  // target: es3 → es2022 (es3 is removed in TS7).
@@ -2440,9 +2551,10 @@ const esLevelRank = (value) => ES_LEVEL_RANK[value.toLowerCase().replace(/\.full
2440
2551
  /** tsc says "Try changing the 'lib' compiler option to 'es2023' or later"
2441
2552
  * (TS2550/TS2583/TS2584) when the code uses a standard-library API newer than
2442
2553
  * 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.
2554
+ * moved past — so raise the level and let the build try again, rather than
2555
+ * leaving the user to decode `replaceAll` and `toSorted` errors by hand. The
2556
+ * level asked for only decides *whether* to raise; what we write is
2557
+ * `RAISED_ES_LEVEL`, so the next new method used doesn't start this over.
2446
2558
  *
2447
2559
  * We raise `target`: its implied `lib` keeps the DOM declarations a bare
2448
2560
  * `lib: ["es2023"]` would silently drop. An explicit `lib` overrides `target`
@@ -2506,12 +2618,12 @@ function raiseTsconfigLibLevel(cwd, buildOutput, snapshot) {
2506
2618
  const entries = lib.value.filter((e) => typeof e === 'string');
2507
2619
  if (entries.some(e => esLevelRank(e) >= esLevelRank(required)))
2508
2620
  return false;
2509
- const raised = [required, ...entries.filter(e => esLevelRank(e) === 0)];
2621
+ const raised = [RAISED_ES_LEVEL, ...entries.filter(e => esLevelRank(e) === 0)];
2510
2622
  const tgt = targetFor(lib.definedIn);
2511
2623
  if (!edit(tgt.path, t => upsertCompilerOption(t, 'lib', JSON.stringify(raised).replace(/","/g, '", "'))))
2512
2624
  return false;
2513
2625
  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.`);
2626
+ recordBuildIssue(name, 'warning', `Raised "lib" to ${RAISED_ES_LEVEL} — the code uses standard-library APIs newer than the tsconfig allowed (needs ${required}).`);
2515
2627
  return true;
2516
2628
  }
2517
2629
  const tg = effectiveCompilerOption(chain, 'target');
@@ -2519,10 +2631,10 @@ function raiseTsconfigLibLevel(cwd, buildOutput, snapshot) {
2519
2631
  if (esLevelRank(current) >= esLevelRank(required))
2520
2632
  return false;
2521
2633
  const tgt = targetFor(tg?.definedIn);
2522
- if (!edit(tgt.path, t => upsertCompilerOption(t, 'target', `"${required}"`)))
2634
+ if (!edit(tgt.path, t => upsertCompilerOption(t, 'target', `"${RAISED_ES_LEVEL}"`)))
2523
2635
  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.`);
2636
+ console.log(colors.cyan(` Raised target "${current}" → "${RAISED_ES_LEVEL}" in ${path.relative(cwd, tgt.path) || 'tsconfig.json'} (build needs ${required})`));
2637
+ recordBuildIssue(name, 'warning', `Raised "target" ${current} → ${RAISED_ES_LEVEL} — the code uses standard-library APIs newer than the tsconfig allowed (needs ${required}).`);
2526
2638
  return true;
2527
2639
  }
2528
2640
  /** Write back the texts captured in a `migrateTsconfigDeprecations` snapshot
@@ -3197,8 +3309,21 @@ export async function buildProject(cwd, opts = {}) {
3197
3309
  if (buildOutput)
3198
3310
  console.error(buildOutput);
3199
3311
  console.error(colors.red(`Build failed in ${pkg.name || cwd}`));
3312
+ const label = pkg.name || path.basename(cwd);
3313
+ // tsc reports a missing-declarations failure against the file that imports the
3314
+ // dep, and suggests an @types package that does not exist for private scopes.
3315
+ // When the real cause is a dep installed without any .d.ts, say so plainly and
3316
+ // put THAT in the summary — the raw tsc line is already echoed above.
3317
+ const untyped = diagnoseUntypedDeps(cwd, buildOutput);
3318
+ if (untyped.length) {
3319
+ for (const line of untyped) {
3320
+ console.error(colors.yellow(` ${line}`));
3321
+ recordBuildIssue(label, 'error', line);
3322
+ }
3323
+ return false;
3324
+ }
3200
3325
  const firstErr = extractFirstTscError(buildOutput);
3201
- recordBuildIssue(pkg.name || path.basename(cwd), 'error', firstErr || 'Build failed');
3326
+ recordBuildIssue(label, 'error', firstErr || 'Build failed');
3202
3327
  return false;
3203
3328
  }
3204
3329
  /** Walk `file:` deps depth-first (deps before consumers) and build each one
@@ -3249,6 +3374,18 @@ export async function buildFileDepsTopologically(cwd, opts = {}, visited = new S
3249
3374
  }
3250
3375
  /** `moduleResolution` values TypeScript 6 deprecated and TypeScript 7 removes. */
3251
3376
  const DEPRECATED_MODULE_RESOLUTION = new Set(['node', 'node10', 'classic']);
3377
+ /** Spelling the tsconfig JSON schema — and so the editor linting built on it,
3378
+ * e.g. webhint via "Microsoft Edge Tools" — expects for the values we write.
3379
+ * TypeScript reads them case-insensitively; the schema does not. Deliberately
3380
+ * limited to the values npmglobalize writes itself: re-casing whatever the user
3381
+ * hand-typed would be churn, not a fix. */
3382
+ const CANONICAL_NODENEXT = 'NodeNext';
3383
+ const CANONICAL_CASING = { nodenext: CANONICAL_NODENEXT, node16: 'Node16' };
3384
+ /** Level we raise a too-old `target`/`lib` to. These are Node packages built and
3385
+ * run on the current Node, so tracking the newest language level costs nothing
3386
+ * and saves a re-raise every time the code picks up a newer standard-library
3387
+ * method. */
3388
+ const RAISED_ES_LEVEL = 'ESNext';
3252
3389
  /** Compiler flags TypeScript deprecated (TS5101) and will remove in TS7. Their
3253
3390
  * mere presence — at any value — is a migration item. */
3254
3391
  const DEPRECATED_TS7_FLAGS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.204",
3
+ "version": "1.0.206",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "@bobfrankston/freezepak": "^0.1.9",
35
35
  "@bobfrankston/importgen": "^0.1.40",
36
36
  "@bobfrankston/themecolors": "^0.1.8",
37
- "@bobfrankston/userconfig": "^1.0.10",
37
+ "@bobfrankston/userconfig": "^1.0.11",
38
38
  "@npmcli/package-json": "^7.0.4",
39
39
  "json5": "^2.2.3",
40
40
  "libnpmversion": "^8.0.3",
@@ -62,7 +62,7 @@
62
62
  "@bobfrankston/freezepak": "^0.1.9",
63
63
  "@bobfrankston/importgen": "^0.1.40",
64
64
  "@bobfrankston/themecolors": "^0.1.8",
65
- "@bobfrankston/userconfig": "^1.0.10",
65
+ "@bobfrankston/userconfig": "^1.0.11",
66
66
  "@npmcli/package-json": "^7.0.4",
67
67
  "json5": "^2.2.3",
68
68
  "libnpmversion": "^8.0.3",