@bobfrankston/npmglobalize 1.0.179 → 1.0.181

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 +12 -0
  2. package/cli.js +10 -0
  3. package/lib.d.ts +12 -0
  4. package/lib.js +182 -1
  5. package/package.json +1 -1
package/README.md CHANGED
@@ -427,6 +427,18 @@ Workspace mode is auto-detected when run from a root with `"private": true` and
427
427
  history instead of creating a fresh repo.
428
428
  -adopt Strict adopt: require a reachable git remote in
429
429
  package.json.repository. Abort if probe fails. Skips prompt.
430
+ -strict-imports, -import-check
431
+ Opt in to scanning .ts/.js source for imports of packages not
432
+ declared in any dependencies bucket. Off by default — the
433
+ always-declare style this enforces doesn't hold in monorepos
434
+ that rely on workspace cross-refs or the -public-deps cascade
435
+ (those produce noisy prompts that get dismissed reflexively,
436
+ which defeats the safety purpose). Use only on packages where
437
+ every import is meant to be a direct package.json declaration.
438
+ When it does fire, it catches silent runtime failures —
439
+ ERR_MODULE_NOT_FOUND on a clean install of a published tarball
440
+ that was resolving via an ambient parent/global node_modules
441
+ at dev time.
430
442
  -force Continue despite git errors
431
443
  -dry-run Preview what would happen
432
444
  -quiet Suppress npm warnings (default)
package/cli.js CHANGED
@@ -86,6 +86,12 @@ Other Options:
86
86
  in package.json.repository if reachable, else fresh init)
87
87
  -adopt Strict adopt: require a reachable git remote in
88
88
  package.json.repository. Abort if probe fails. Skips prompt.
89
+ -strict-imports, -import-check
90
+ Scan .ts/.js for imports not declared in any deps bucket and
91
+ warn before publish. Off by default — the always-declare
92
+ style this enforces doesn't hold for monorepos that rely on
93
+ workspace cross-refs / -public-deps cascade. Opt in only
94
+ when the assumption holds in your codebase.
89
95
  -force Continue despite git errors
90
96
  -dry-run Preview what would happen
91
97
  -quiet Suppress npm warnings (default)
@@ -229,6 +235,10 @@ function parseArgs(args) {
229
235
  case '-adopt':
230
236
  options.adopt = true;
231
237
  break;
238
+ case '-strict-imports':
239
+ case '-import-check':
240
+ options.importCheck = true;
241
+ break;
232
242
  case '-git':
233
243
  i++;
234
244
  if (args[i] === 'private' || args[i] === 'public') {
package/lib.d.ts CHANGED
@@ -119,6 +119,12 @@ export interface GlobalizeOptions {
119
119
  * package.json.repository. Aborts (rather than creating a fresh repo) if
120
120
  * the probe fails. Skips the no-git prompt. */
121
121
  adopt?: boolean;
122
+ /** Run the undeclared-imports scan (`-strict-imports`). Default false —
123
+ * the scan assumes a style where every import is declared in the
124
+ * consuming package's package.json, which doesn't hold for monorepos
125
+ * that rely on workspace cross-refs / -public-deps cascade. Opt in when
126
+ * the assumption holds. */
127
+ importCheck?: boolean;
122
128
  /** Internal: signals this call is from workspace orchestrator */
123
129
  /** Skip the upfront dep-graph prescan */
124
130
  noPrescan?: boolean;
@@ -260,6 +266,12 @@ export interface PrescanIssue {
260
266
  message: string;
261
267
  suggestion?: string;
262
268
  }
269
+ export interface UndeclaredImport {
270
+ name: string;
271
+ file: string;
272
+ line: number;
273
+ }
274
+ export declare function findUndeclaredImports(cwd: string, pkg: any): UndeclaredImport[];
263
275
  /** Walk the full file: dep graph and collect issues up front — missing scopes,
264
276
  * unresolvable paths, missing package.json, unpublished transitives. Lets the
265
277
  * user resolve all problems before starting the publish cascade instead of
package/lib.js CHANGED
@@ -13,6 +13,7 @@ import fs from 'fs';
13
13
  import os from 'os';
14
14
  import path from 'path';
15
15
  import { execSync, spawn, spawnSync } from 'child_process';
16
+ import { builtinModules } from 'module';
16
17
  import { readConfig as readUserConfig, writeConfig as writeUserConfig, configDir } from '@bobfrankston/userconfig';
17
18
  import { freezeDependencies } from '@bobfrankston/freezepak';
18
19
  import { importgen as runImportgen } from '@bobfrankston/importgen';
@@ -1304,6 +1305,149 @@ function printDepTree(baseDir, indent = 0, visited = new Set()) {
1304
1305
  }
1305
1306
  }
1306
1307
  }
1308
+ /** Source-import scan: finds package imports in .ts/.js that aren't declared
1309
+ * in any of the package.json dep buckets. Catches the failure mode where a
1310
+ * file imports `@scope/thing` but no one ever added it to dependencies — the
1311
+ * tarball publishes "fine" and crashes with ERR_MODULE_NOT_FOUND on install.
1312
+ * The previously-working case usually relies on a global/parent copy that
1313
+ * Node ESM resolution happened to find. */
1314
+ const SOURCE_SKIP_DIRS = new Set([
1315
+ 'node_modules', 'prev', '.git', 'dist', 'build', 'out',
1316
+ 'coverage', '.next', '.nuxt', '.turbo', '.cache'
1317
+ ]);
1318
+ function collectSourceFiles(dir, acc = [], depth = 0) {
1319
+ if (depth > 12)
1320
+ return acc;
1321
+ let entries;
1322
+ try {
1323
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1324
+ }
1325
+ catch {
1326
+ return acc;
1327
+ }
1328
+ for (const ent of entries) {
1329
+ const name = ent.name;
1330
+ if (ent.isDirectory()) {
1331
+ if (SOURCE_SKIP_DIRS.has(name))
1332
+ continue;
1333
+ if (name.startsWith('.'))
1334
+ continue; // .git, .vscode, .idea, etc.
1335
+ collectSourceFiles(path.join(dir, name), acc, depth + 1);
1336
+ }
1337
+ else if (ent.isFile()) {
1338
+ if (name.endsWith('.d.ts') || name.endsWith('.map'))
1339
+ continue;
1340
+ if (name.endsWith('.ts') || name.endsWith('.tsx')
1341
+ || name.endsWith('.js') || name.endsWith('.jsx')
1342
+ || name.endsWith('.mjs') || name.endsWith('.cjs')) {
1343
+ acc.push(path.join(dir, name));
1344
+ }
1345
+ }
1346
+ }
1347
+ return acc;
1348
+ }
1349
+ /** npm package name rules (simplified): lowercase letters, digits, `-`, `_`, `.`,
1350
+ * optionally with an `@scope/` prefix. No spaces, no `<>`, no uppercase. */
1351
+ const NPM_NAME_RE = /^(@[a-z0-9_.~-]+\/)?[a-z0-9_.~-]+$/;
1352
+ /** Extract the package name from an ESM/CJS module specifier.
1353
+ * Returns null for relative paths, absolute paths, URLs, node: builtins, and
1354
+ * anything that doesn't look like a valid npm package name (filters out
1355
+ * matches that landed in comments or string literals). */
1356
+ function specifierToPackageName(spec) {
1357
+ if (!spec)
1358
+ return null;
1359
+ if (spec.startsWith('.'))
1360
+ return null; // ./ or ../
1361
+ if (spec.startsWith('/'))
1362
+ return null; // /abs
1363
+ if (spec.startsWith('node:'))
1364
+ return null; // node:fs
1365
+ if (/^[a-z][a-z0-9+.-]*:/i.test(spec))
1366
+ return null; // url/data
1367
+ if (spec.startsWith('#'))
1368
+ return null; // subpath import
1369
+ let candidate;
1370
+ if (spec.startsWith('@')) {
1371
+ const parts = spec.split('/');
1372
+ if (parts.length < 2)
1373
+ return null;
1374
+ candidate = `${parts[0]}/${parts[1]}`;
1375
+ }
1376
+ else {
1377
+ candidate = spec.split('/')[0];
1378
+ }
1379
+ // Reject anything that isn't a plausible npm name — this filters out
1380
+ // matches inside string literals / comments like "Bob <bob@gmail.com>"
1381
+ // or "All Inboxes" that slipped through the regex.
1382
+ if (!NPM_NAME_RE.test(candidate))
1383
+ return null;
1384
+ return candidate;
1385
+ }
1386
+ const NODE_BUILTIN_NAMES = new Set(builtinModules);
1387
+ export function findUndeclaredImports(cwd, pkg) {
1388
+ const declared = new Set();
1389
+ for (const k of DEP_KEYS) {
1390
+ const bucket = pkg[k] || pkg['.' + k]; // also consult the backup bucket if a transform is pending
1391
+ if (bucket)
1392
+ for (const n of Object.keys(bucket))
1393
+ declared.add(n);
1394
+ }
1395
+ const selfName = pkg.name;
1396
+ // Anchored to line start (with optional leading whitespace) so that a
1397
+ // comment like `// imported from "Bob <bob@gmail.com>"` or an inline
1398
+ // string literal `"received from 'x'"` can't trigger the regex. This is
1399
+ // intentionally stricter than full JS parsing — minified one-line imports
1400
+ // are missed, but those are vendor bundles where false negatives are far
1401
+ // better than the false-positive noise.
1402
+ const IMPORT_FROM_RE = /^\s*(?:import|export)\s(?:[^'"`;]+\s)?from\s+['"]([^'"]+)['"]/g;
1403
+ const IMPORT_BARE_RE = /^\s*import\s+['"]([^'"]+)['"]/g;
1404
+ const DYNAMIC_RE = /(?:^|[^.\w$])import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
1405
+ const REQUIRE_RE = /(?:^|[^.\w$])require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
1406
+ const PATTERNS = [IMPORT_FROM_RE, IMPORT_BARE_RE, DYNAMIC_RE, REQUIRE_RE];
1407
+ const findings = new Map();
1408
+ const files = collectSourceFiles(cwd);
1409
+ for (const f of files) {
1410
+ let content;
1411
+ try {
1412
+ content = fs.readFileSync(f, 'utf-8');
1413
+ }
1414
+ catch {
1415
+ continue;
1416
+ }
1417
+ // Quick reject: no import/require keyword anywhere → skip
1418
+ if (!/\bimport\b|\brequire\s*\(/.test(content))
1419
+ continue;
1420
+ const lines = content.split('\n');
1421
+ for (let i = 0; i < lines.length; i++) {
1422
+ const line = lines[i];
1423
+ for (const re of PATTERNS) {
1424
+ re.lastIndex = 0;
1425
+ let m;
1426
+ while ((m = re.exec(line))) {
1427
+ const spec = m[1];
1428
+ const pkgName = specifierToPackageName(spec);
1429
+ if (!pkgName)
1430
+ continue;
1431
+ if (NODE_BUILTIN_NAMES.has(pkgName))
1432
+ continue;
1433
+ if (pkgName === selfName)
1434
+ continue;
1435
+ if (declared.has(pkgName))
1436
+ continue;
1437
+ if (!findings.has(pkgName)) {
1438
+ findings.set(pkgName, {
1439
+ file: path.relative(cwd, f).replace(/\\/g, '/'),
1440
+ line: i + 1
1441
+ });
1442
+ }
1443
+ }
1444
+ }
1445
+ }
1446
+ }
1447
+ return [...findings.entries()]
1448
+ .map(([name, loc]) => ({ name, ...loc }))
1449
+ .sort((a, b) => a.name.localeCompare(b.name));
1450
+ }
1307
1451
  /** Walk the full file: dep graph and collect issues up front — missing scopes,
1308
1452
  * unresolvable paths, missing package.json, unpublished transitives. Lets the
1309
1453
  * user resolve all problems before starting the publish cascade instead of
@@ -3411,7 +3555,7 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
3411
3555
  const { bump = 'patch', noPublish = false, cleanup = false, install = false, link = false, wsl = false, force = false, files = true, dryRun = false, quiet = true, verbose = false, init = false, gitVisibility = 'private', npmVisibility = 'private', message, conform = false, asis = false, updateDeps = false, updateMajor = false, publishDeps = true, // Default to publishing deps for safety
3412
3556
  publishDepsYes = false, // -pd: auto-yes to dep-cascade prompts (private only)
3413
3557
  publicDeps = false, // -public-deps: cascade public visibility to all deps
3414
- noPrescan = false, forcePublish = false, fix = true, fixTags = false, rebase = false, show = false, local = false, freeze = false, usePaths = true, allowTs, adopt = false } = options;
3558
+ noPrescan = false, forcePublish = false, fix = true, fixTags = false, rebase = false, show = false, local = false, freeze = false, usePaths = true, allowTs, adopt = false, importCheck = false } = options;
3415
3559
  // Show tool version only for recursive dep calls (CLI already prints it at startup)
3416
3560
  const toolVersion = getToolVersion();
3417
3561
  if (!options._fromWorkspace && !options._fromCli) {
@@ -3554,6 +3698,43 @@ export async function globalize(cwd, options = {}, configOptions = {}) {
3554
3698
  console.log(colors.dim('Prescan: no issues found in dep graph.'));
3555
3699
  }
3556
3700
  }
3701
+ // Source-import scan — catches imports of packages not declared in
3702
+ // dependencies/devDependencies/peer/optional. Opt-in via -strict-imports
3703
+ // because the always-declare style this check enforces doesn't hold in
3704
+ // monorepos that rely on workspace cross-refs / -public-deps. Only at the
3705
+ // top level (deps in the cascade will be scanned when their own
3706
+ // globalize() runs).
3707
+ if (importCheck && !cleanup && !options._fromDep && !options._fromWorkspace) {
3708
+ const scanPkg = readPackageJson(cwd);
3709
+ const undeclared = findUndeclaredImports(cwd, scanPkg);
3710
+ if (undeclared.length > 0) {
3711
+ console.log('');
3712
+ console.log(colors.yellow(`Undeclared imports found (${undeclared.length}):`));
3713
+ for (const u of undeclared) {
3714
+ console.log(colors.yellow(` ${u.name}`)
3715
+ + colors.dim(` (${u.file}:${u.line})`));
3716
+ }
3717
+ console.log(colors.dim(' These packages are imported but not in any package.json dep bucket.'));
3718
+ console.log(colors.dim(' After publish, `npm install` won\'t pull them and the package will'));
3719
+ console.log(colors.dim(' crash with ERR_MODULE_NOT_FOUND on a clean install.'));
3720
+ const interactive = !init && !options.autoInit && !publishDepsYes && !dryRun;
3721
+ if (interactive) {
3722
+ const ok = await confirm('Continue anyway?', false);
3723
+ if (!ok) {
3724
+ console.log('Aborted. Add the missing packages to dependencies and re-run.');
3725
+ console.log(colors.dim(' Tip: for siblings, use "file:../<dir>"; otherwise add the latest published version.'));
3726
+ return false;
3727
+ }
3728
+ }
3729
+ else {
3730
+ console.log(colors.yellow(' (non-interactive mode — continuing, but the install will likely fail at runtime)'));
3731
+ }
3732
+ console.log('');
3733
+ }
3734
+ else if (verbose) {
3735
+ console.log(colors.dim('Import scan: all imports are declared.'));
3736
+ }
3737
+ }
3557
3738
  // Check ignore files first (unless cleanup mode)
3558
3739
  if (!cleanup && !asis) {
3559
3740
  const checkResult = checkIgnoreFiles(cwd, { conform, asis, verbose, allowTs });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/npmglobalize",
3
- "version": "1.0.179",
3
+ "version": "1.0.181",
4
4
  "description": "Transform file: dependencies to npm versions for publishing",
5
5
  "main": "index.js",
6
6
  "type": "module",