@webjsdev/server 0.8.20 → 0.8.22

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/src/vendor.js CHANGED
@@ -44,6 +44,7 @@ import { readFile, readdir, writeFile, mkdir, unlink, stat, rename } from 'node:
44
44
  import { readFileSync, existsSync, realpathSync } from 'node:fs';
45
45
  import { join, dirname, basename, sep } from 'node:path';
46
46
  import { createRequire } from 'node:module';
47
+ import { spawnSync } from 'node:child_process';
47
48
  import { digestBase64 } from './crypto-utils.js';
48
49
  import { BUFFERED_MARKER } from './conditional-get.js';
49
50
 
@@ -257,13 +258,48 @@ export function getPackageVersion(pkgName, appDir) {
257
258
  }
258
259
  }
259
260
 
261
+ /**
262
+ * Read the installed package's declared `dependencies` + `peerDependencies`
263
+ * from its `package.json`, hoist-aware (same resolution as `getPackageVersion`,
264
+ * so a monorepo-hoisted dep resolves from the workspace root). Returns null
265
+ * when the package is not installed / unreadable, which the importmap-coherence
266
+ * check (#450) treats as "could not verify" rather than a conflict.
267
+ *
268
+ * This is the "already-resolved metadata, no network" source the coherence
269
+ * check prefers: the package is on disk because the importmap pinned it, so its
270
+ * manifest is a local read.
271
+ *
272
+ * @param {string} pkgName
273
+ * @param {string} appDir
274
+ * @returns {{ dependencies: Record<string,string>, peerDependencies: Record<string,string> } | null}
275
+ */
276
+ export function getPackageManifest(pkgName, appDir) {
277
+ const real = resolvePackageDir(pkgName, appDir);
278
+ if (!real) return null;
279
+ try {
280
+ const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8'));
281
+ return {
282
+ dependencies: pkg.dependencies || {},
283
+ peerDependencies: pkg.peerDependencies || {},
284
+ };
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+
260
290
  // ---------------------------------------------------------------------------
261
291
  // JSPM Generator API client
262
292
  // ---------------------------------------------------------------------------
263
293
 
264
294
  /**
265
295
  * In-memory cache of resolved importmap fragments from api.jspm.io.
266
- * Keyed by the sorted+joined list of `pkg@version` install specs.
296
+ * Two kinds of key share this map:
297
+ * - The UNIFIED key (`<provider>::unified::<sorted installs joined>`)
298
+ * caches the whole-set resolve produced by one `generate` call, the
299
+ * default path (issue #446).
300
+ * - The PER-INSTALL key (`<provider>::<install>`) caches a single
301
+ * install's isolated resolve, used only on the fallback path when the
302
+ * unified call fails because some install is unresolvable.
267
303
  * Per-process; cleared by `clearVendorCache` on file-watcher rebuild
268
304
  * so new versions get re-resolved.
269
305
  *
@@ -309,91 +345,144 @@ export function normalizeProvider(name) {
309
345
  }
310
346
 
311
347
  /**
312
- * Resolve a SINGLE `pkg@version` (or `pkg@version/subpath`) install via
313
- * api.jspm.io/generate. Returns the imports fragment (typically one or
314
- * two entries; subpath installs sometimes include the root package).
315
- *
316
- * Per-package isolation is the whole point: api.jspm.io/generate fails
317
- * the ENTIRE batch with a 401 when any single package can't be
318
- * resolved (e.g. a transitive that has no jspm.io-compatible exports).
319
- * Calling per-package means one bad dep can no longer poison the
320
- * importmap for legitimate deps.
321
- *
322
- * Cached in-process by the install spec + provider. Failures are
323
- * logged loudly with the package name and the reason jspm.io
324
- * returned.
348
+ * Outcome of one api.jspm.io/generate POST.
349
+ * @typedef {Object} JspmCallResult
350
+ * @property {boolean} ok true when jspm returned a 2xx with a usable map
351
+ * @property {Record<string, string>} imports the resolved imports (empty on failure)
352
+ * @property {boolean} transient true when the failure is worth retrying
353
+ * (network / timeout / 5xx / 429), false for a permanent 4xx
354
+ * (jspm uses 401 for "this install is unresolvable")
355
+ */
356
+
357
+ /**
358
+ * Make ONE api.jspm.io/generate POST for a list of installs and return a
359
+ * structured result. The single point that talks to the network; both the
360
+ * unified path and the per-install fallback funnel through it.
361
+ *
362
+ * jspm fails the WHOLE batch (401) when ANY one install is unresolvable, so
363
+ * a multi-install POST is all-or-nothing: either the entire coherent graph
364
+ * comes back, or nothing does. `jspmGenerate` uses that property to decide
365
+ * when to fall back to per-install isolation.
366
+ *
367
+ * @param {Array<string>} installs e.g. ['dayjs@1.11.13', '@codemirror/lint@6.9.6']
368
+ * @param {string} provider one of SUPPORTED_PROVIDERS
369
+ * @returns {Promise<JspmCallResult>}
370
+ */
371
+ async function jspmCall(installs, provider) {
372
+ const label = installs.length === 1 ? `'${installs[0]}'` : `${installs.length} packages`;
373
+ const controller = new AbortController();
374
+ const timer = setTimeout(() => controller.abort(), JSPM_GENERATE_TIMEOUT_MS);
375
+ try {
376
+ const response = await fetch(JSPM_GENERATE_ENDPOINT, {
377
+ method: 'POST',
378
+ headers: { 'content-type': 'application/json' },
379
+ body: JSON.stringify({
380
+ install: installs,
381
+ // flattenScope:true merges transitive ESM deps into the flat
382
+ // `imports` map instead of a separate `scopes` field. Webjs only
383
+ // consumes `imports`, so without this any package with an
384
+ // unbundled ESM transitive (e.g. react-dom imports `scheduler`,
385
+ // @codemirror/lint imports @codemirror/state) would break in the
386
+ // browser with an unresolved-bare-specifier error. With the
387
+ // WHOLE-set call (issue #446) the flattened transitives are now
388
+ // ALSO mutually consistent: one `@codemirror/view` URL shared by
389
+ // the direct import and lint's transitive need, instead of two
390
+ // skewed versions from independent per-package calls.
391
+ flattenScope: true,
392
+ env: ['browser', 'production', 'module'],
393
+ provider: normalizeProvider(provider),
394
+ }),
395
+ signal: controller.signal,
396
+ });
397
+ if (!response.ok) {
398
+ // jspm.io returns the error reason in the body with a 401 (its
399
+ // quirk: 401 is what it sends for unresolvable installs, not auth
400
+ // failures). Surface it so the user sees WHAT failed and why.
401
+ let detail = '';
402
+ try {
403
+ const body = await response.json();
404
+ if (body && typeof body.error === 'string') detail = `: ${body.error}`;
405
+ } catch { /* non-JSON body */ }
406
+ console.error(
407
+ `[webjs] could not vendor ${label} via ${provider} (status ${response.status})${detail}`,
408
+ );
409
+ // A 5xx/429 is a transient jspm problem worth retrying. A 401/4xx
410
+ // means at least one install is genuinely unresolvable (jspm uses
411
+ // 401 for that): a private / workspace / server-only package (e.g.
412
+ // @webjsdev/server, @prisma/client) the browser never fetches
413
+ // anyway. Permanent failures must NOT block readiness.
414
+ const transient = response.status >= 500 || response.status === 429;
415
+ return { ok: false, imports: {}, transient };
416
+ }
417
+ const result = await response.json();
418
+ const imports = (result && result.map && result.map.imports) || {};
419
+ return { ok: true, imports, transient: false };
420
+ } catch (e) {
421
+ const msg = e && e.name === 'AbortError'
422
+ ? `timed out after ${JSPM_GENERATE_TIMEOUT_MS}ms`
423
+ : `${e && e.message}`;
424
+ console.error(`[webjs] could not vendor ${label} via ${provider}: ${msg}`);
425
+ return { ok: false, imports: {}, transient: true };
426
+ } finally {
427
+ clearTimeout(timer);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Resolve a SINGLE install in isolation, cached per install + provider.
433
+ * This is the FALLBACK path: it only runs when the unified whole-set call
434
+ * fails because some install is unresolvable. Isolating each install means
435
+ * one bad dep (a 401) drops out on its own instead of collapsing the map
436
+ * for its legitimate neighbours. The cross-package coherence the unified
437
+ * call provides is lost for this degraded set, which is acceptable: it is
438
+ * exactly the pre-#446 behaviour, reached only when the app already has an
439
+ * unresolvable dep.
440
+ *
441
+ * Sets `lastLiveResolveFailed` on a TRANSIENT failure (so the caller
442
+ * retries), never on a permanent 401 (tolerated).
325
443
  *
326
444
  * @param {string} install e.g. 'dayjs@1.11.13' or 'dayjs@1.11.13/plugin/utc'
327
445
  * @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
328
446
  * @returns {Promise<Record<string, string>>}
329
447
  */
330
448
  async function jspmResolveOne(install, provider = 'jspm') {
331
- // Cache key includes provider since the same install can resolve
332
- // to different URLs across CDNs (e.g. `dayjs@1.11.13` returns
333
- // ga.jspm.io vs cdn.jsdelivr.net depending on `provider`).
334
- const cacheKey = `${provider}::${install}`;
449
+ const { ok, imports, transient } = await jspmProbeOne(install, provider);
450
+ // Preserve the public contract: an empty map on any failure, and the
451
+ // module-global retry flag set ONLY on a transient one (a permanent 401
452
+ // for an unresolvable private/server-only dep is tolerated).
453
+ if (!ok && transient) lastLiveResolveFailed = true;
454
+ return imports;
455
+ }
456
+
457
+ /**
458
+ * Probe a SINGLE install and return the FULL classification, not just the
459
+ * imports. Unlike `jspmResolveOne` this does NOT collapse a transient
460
+ * failure into the same empty map a permanent one yields, and does NOT
461
+ * touch `lastLiveResolveFailed`: the caller (the 401 fallback in
462
+ * `jspmGenerate`) needs to tell "genuinely unresolvable, safe to drop"
463
+ * (`ok:false, transient:false`) from "a network blip mid-probe, do NOT
464
+ * drop" (`ok:false, transient:true`), and owns the retry flag itself.
465
+ *
466
+ * Cached per install + provider; a successful probe's `{imports}` is the
467
+ * same value `jspmResolveOne` returns, so the two share the cache and the
468
+ * later unified re-run reuses it.
469
+ *
470
+ * @param {string} install
471
+ * @param {string} provider
472
+ * @returns {Promise<JspmCallResult>}
473
+ */
474
+ function jspmProbeOne(install, provider) {
475
+ const cacheKey = `${provider}::probe::${install}`;
335
476
  const existing = jspmCache.get(cacheKey);
336
477
  if (existing) return existing;
337
478
 
338
479
  const promise = (async () => {
339
- const controller = new AbortController();
340
- const timer = setTimeout(() => controller.abort(), JSPM_GENERATE_TIMEOUT_MS);
341
- try {
342
- const response = await fetch(JSPM_GENERATE_ENDPOINT, {
343
- method: 'POST',
344
- headers: { 'content-type': 'application/json' },
345
- body: JSON.stringify({
346
- install: [install],
347
- // flattenScope:true merges transitive ESM deps into the
348
- // flat `imports` map instead of returning them in a
349
- // separate `scopes` field. Webjs only consumes `imports`
350
- // (the importmap output doesn't carry `scopes`), so
351
- // without this any package with an unbundled ESM
352
- // transitive (e.g. react-dom imports `scheduler`)
353
- // would break in the browser with an unresolved-bare-
354
- // specifier error. Matches importmap-rails's posture.
355
- flattenScope: true,
356
- env: ['browser', 'production', 'module'],
357
- provider: normalizeProvider(provider),
358
- }),
359
- signal: controller.signal,
360
- });
361
- if (!response.ok) {
362
- // jspm.io returns the error reason in the body with a 401 (its
363
- // quirk: 401 is what it sends for unresolvable installs, not
364
- // auth failures). Surface it so the user sees WHICH dep failed
365
- // and why, not just a generic "vendor pipeline broken".
366
- let detail = '';
367
- try {
368
- const body = await response.json();
369
- if (body && typeof body.error === 'string') detail = `: ${body.error}`;
370
- } catch { /* non-JSON body */ }
371
- console.error(
372
- `[webjs] could not vendor '${install}' via ${provider} (status ${response.status})${detail}`,
373
- );
374
- jspmCache.delete(cacheKey);
375
- // A 5xx/429 is a transient jspm problem worth retrying. A 401/4xx means
376
- // the install is genuinely unresolvable (jspm uses 401 for that): a
377
- // private / workspace / server-only package (e.g. @webjsdev/server,
378
- // @prisma/client) the browser never fetches anyway. That is tolerated
379
- // exactly as before and must NOT block readiness, or an app with any
380
- // such dep would never become ready.
381
- if (response.status >= 500 || response.status === 429) lastLiveResolveFailed = true;
382
- return {};
383
- }
384
- const result = await response.json();
385
- return (result && result.map && result.map.imports) || {};
386
- } catch (e) {
387
- const msg = e && e.name === 'AbortError'
388
- ? `timed out after ${JSPM_GENERATE_TIMEOUT_MS}ms`
389
- : `${e && e.message}`;
390
- console.error(`[webjs] could not vendor '${install}' via ${provider}: ${msg}`);
391
- jspmCache.delete(cacheKey);
392
- lastLiveResolveFailed = true;
393
- return {};
394
- } finally {
395
- clearTimeout(timer);
396
- }
480
+ const result = await jspmCall([install], provider);
481
+ // Do not cache a failure: a transient one must be re-attempted on the
482
+ // next resolve, and a permanent one is cheap to re-confirm and must not
483
+ // pin a stale "unresolvable" verdict across a dependency change.
484
+ if (!result.ok) jspmCache.delete(cacheKey);
485
+ return result;
397
486
  })();
398
487
 
399
488
  jspmCache.set(cacheKey, promise);
@@ -401,15 +490,35 @@ async function jspmResolveOne(install, provider = 'jspm') {
401
490
  }
402
491
 
403
492
  /**
404
- * Resolve a list of `pkg@version` installs to importmap entries by
405
- * calling api.jspm.io/generate ONCE PER INSTALL in parallel. Per-package
406
- * isolation prevents one bad dep from collapsing the whole importmap
407
- * (see jspmResolveOne for the rationale).
408
- *
409
- * The merge is last-write-wins per key. In practice subpath installs
410
- * never collide with each other (their keys include the subpath), and
411
- * the bare-package install for `dayjs` always produces the same root
412
- * URL as `dayjs@x.y.z/plugin/foo`'s incidental `dayjs` entry.
493
+ * Resolve a list of `pkg@version` installs to importmap entries.
494
+ *
495
+ * Issue #446: the WHOLE set is resolved in ONE api.jspm.io/generate call
496
+ * (a single `install[]` array) so jspm computes one mutually-consistent
497
+ * dependency graph. A directly-imported package and a transitive that
498
+ * needs a newer version of the same package now agree on one URL, instead
499
+ * of the old per-package-in-isolation merge that pinned the direct dep to
500
+ * its local version while the transitive floated to jspm-latest, producing
501
+ * a missing-export crash in the browser.
502
+ *
503
+ * The per-package-isolation property is PRESERVED as a fallback only: if
504
+ * the unified call fails because some install is unresolvable (a 401 for a
505
+ * private / server-only dep), one bad install must not collapse the map
506
+ * for the rest. So:
507
+ * 1. Try the unified call. On success, return its coherent graph.
508
+ * 2. On a PERMANENT failure (401/4xx), probe each install in isolation
509
+ * to learn which ones resolve, then RE-RUN the unified call over only
510
+ * the resolvable subset so the survivors stay mutually consistent.
511
+ * Only installs whose probe fails PERMANENTLY drop out (genuinely
512
+ * unresolvable, the browser never fetched them anyway); if any probe
513
+ * fails TRANSIENTLY, no one is dropped and the resolve is flagged for
514
+ * retry, so a network blip mid-probe cannot evict a good package. If
515
+ * the re-run itself fails, fall back to the merged per-install
516
+ * fragments so the app is no worse off than pre-#446.
517
+ * 3. On a TRANSIENT failure (network / timeout / 5xx / 429), set the
518
+ * retry flag and serve whatever the per-install probe produced.
519
+ *
520
+ * The unified result is cached per sorted-install-set + provider; the
521
+ * per-install fallback reuses the per-install cache entries.
413
522
  *
414
523
  * @param {Array<string>} installs e.g. ['dayjs@1.11.13', 'clsx@2.1.1']
415
524
  * @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
@@ -417,9 +526,95 @@ async function jspmResolveOne(install, provider = 'jspm') {
417
526
  */
418
527
  export async function jspmGenerate(installs, provider = 'jspm') {
419
528
  if (installs.length === 0) return {};
420
- const perPackage = await Promise.all(installs.map(i => jspmResolveOne(i, provider)));
529
+
530
+ // A single install has no cross-package graph to reconcile, so the
531
+ // isolated path IS the coherent path; reuse the per-install cache.
532
+ if (installs.length === 1) return jspmResolveOne(installs[0], provider);
533
+
534
+ // Stable key regardless of scan order so the same dep set hits cache.
535
+ const unifiedKey = `${provider}::unified::${[...installs].sort().join('\n')}`;
536
+ const cached = jspmCache.get(unifiedKey);
537
+ if (cached) return cached;
538
+
539
+ const promise = (async () => {
540
+ const unified = await jspmCall(installs, provider);
541
+ if (unified.ok) return unified.imports;
542
+
543
+ // The unified call failed. Drop the cached failure so a later retry
544
+ // re-attempts; the per-install fallback owns the retry flag.
545
+ jspmCache.delete(unifiedKey);
546
+
547
+ if (unified.transient) {
548
+ // Network / 5xx: nothing resolved coherently. Fall back to merged
549
+ // per-install fragments (each may still be cached / reachable) so we
550
+ // serve whatever we can, and flag the transient failure for retry.
551
+ lastLiveResolveFailed = true;
552
+ return mergePerInstall(await Promise.all(installs.map(i => jspmResolveOne(i, provider))));
553
+ }
554
+
555
+ // Permanent failure: at least one install is unresolvable. Probe each
556
+ // in isolation to learn which ones jspm can resolve, then re-run the
557
+ // unified call over only those so the survivors form one consistent
558
+ // graph (restores #446 coherence for the resolvable subset).
559
+ const probes = await Promise.all(installs.map(i => jspmProbeOne(i, provider)));
560
+
561
+ // A GOOD package whose isolated probe failed TRANSIENTLY (a network blip
562
+ // mid-probe) must NOT be classified as unresolvable and dropped. Only a
563
+ // PERMANENT probe failure (401/404) means the install is genuinely
564
+ // unresolvable. If any probe failed transiently, we cannot safely decide
565
+ // the resolvable set this pass, so flag the whole resolve transient-
566
+ // failed and serve the merged fragments WITHOUT dropping anyone; the next
567
+ // ensureReady retry re-resolves once the blip clears. Conflating the two
568
+ // here is exactly the bug this guard prevents.
569
+ const transientProbe = probes.some(p => !p.ok && p.transient);
570
+ if (transientProbe) {
571
+ lastLiveResolveFailed = true;
572
+ return mergePerInstall(probes.map(p => p.imports));
573
+ }
574
+
575
+ // From here every failed probe is PERMANENT, so dropping it is safe.
576
+ const resolvable = installs.filter((_, idx) => probes[idx].ok);
577
+
578
+ if (resolvable.length === installs.length) {
579
+ // Every install resolved alone but the batch 401'd: a genuine
580
+ // cross-package CONFLICT jspm could not satisfy as one graph (rare).
581
+ // The coherent graph is unavailable, so serve the merged fragments
582
+ // (pre-#446 behaviour) rather than nothing. NOTE: this degraded path
583
+ // can REINTRODUCE the #446 skew, because last-write-wins on a shared
584
+ // transitive across independent fragments is exactly the merge the
585
+ // unified call exists to avoid. It is a deliberate degrade-not-crash
586
+ // fallback for an unsatisfiable graph: no coherent graph exists, so a
587
+ // possibly-skewed map beats no map. The common conflicting-deps case
588
+ // (one shared transitive needing a newer version, issue #446's repro)
589
+ // IS satisfiable and resolves coherently on the unified path above;
590
+ // only a genuinely unsatisfiable set reaches here.
591
+ return mergePerInstall(probes.map(p => p.imports));
592
+ }
593
+ if (resolvable.length === 0) return {};
594
+ if (resolvable.length === 1) return jspmResolveOne(resolvable[0], provider);
595
+
596
+ // Re-run unified over the resolvable subset. If even that fails (a
597
+ // conflict among the survivors), fall back to their merged fragments.
598
+ const retry = await jspmCall(resolvable, provider);
599
+ if (retry.ok) return retry.imports;
600
+ return mergePerInstall(resolvable.map(i => probes[installs.indexOf(i)].imports));
601
+ })();
602
+
603
+ jspmCache.set(unifiedKey, promise);
604
+ return promise;
605
+ }
606
+
607
+ /**
608
+ * Last-write-wins merge of per-install import fragments. Subpath installs
609
+ * never collide (their keys include the subpath); a shared base package
610
+ * resolves to the same root URL across fragments, so the merge is stable.
611
+ *
612
+ * @param {Array<Record<string, string>>} fragments
613
+ * @returns {Record<string, string>}
614
+ */
615
+ function mergePerInstall(fragments) {
421
616
  const merged = {};
422
- for (const fragment of perPackage) Object.assign(merged, fragment);
617
+ for (const fragment of fragments) Object.assign(merged, fragment);
423
618
  return merged;
424
619
  }
425
620
 
@@ -465,6 +660,30 @@ export function clearVendorCache() {
465
660
  liveIntegrityCache.clear();
466
661
  }
467
662
 
663
+ /**
664
+ * Recover `{ pkg, version, subpath }` for a resolved import spec that was
665
+ * NOT in the directly-scanned set, i.e. a flattened transitive the unified
666
+ * resolve added (issue #446). The bare package name and subpath come from
667
+ * the spec; the version is read out of the resolved CDN URL by locating
668
+ * `<bare>@<version>` in it (same logic `listPinned` uses, which handles
669
+ * every supported provider's URL shape). Returns null when the version
670
+ * can't be parsed, in which case the caller pins the entry by URL anyway
671
+ * but cannot derive a `--download` filename for it.
672
+ *
673
+ * @param {string} spec e.g. '@codemirror/state' or 'dayjs/plugin/utc'
674
+ * @param {string} url the resolved CDN URL for that spec
675
+ * @returns {{ pkg: string, version: string, subpath: string } | null}
676
+ */
677
+ function derivePinParts(spec, url) {
678
+ const pkg = extractPackageName(spec);
679
+ if (!pkg) return null;
680
+ const subpath = spec.slice(pkg.length);
681
+ const escapedBare = pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
682
+ const match = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
683
+ if (!match) return null;
684
+ return { pkg, version: match[1], subpath };
685
+ }
686
+
468
687
  // ---------------------------------------------------------------------------
469
688
  // File-based pin (.webjs/vendor/importmap.json, optional --download bundles)
470
689
  // ---------------------------------------------------------------------------
@@ -482,6 +701,179 @@ function pinFilePath(appDir) {
482
701
  return join(pinDir(appDir), PIN_FILE);
483
702
  }
484
703
 
704
+ /**
705
+ * The three-line `.gitignore` pattern that ignores the transient
706
+ * `.webjs` caches at any depth while re-including the committed
707
+ * `.webjs/vendor/` pin output. This mirrors the scaffold template
708
+ * (`packages/cli/templates/.gitignore`) and the `vendor-gitignore`
709
+ * check in `doctor.js` verbatim, so a self-healed `.gitignore`
710
+ * ends up byte-identical to a freshly scaffolded one.
711
+ */
712
+ const VENDOR_GITIGNORE_LINES = [
713
+ '**/.webjs/*',
714
+ '!**/.webjs/vendor/',
715
+ '!**/.webjs/vendor/**',
716
+ ];
717
+
718
+ /**
719
+ * Probe whether `appDir`'s `.gitignore` would swallow the vendor pin
720
+ * output, via `git check-ignore`. Best-effort: returns false when the
721
+ * directory is not a git repo, git is absent, or the spawn fails.
722
+ *
723
+ * The inherited GIT_* env vars are stripped so `cwd` is the sole
724
+ * authority on which repo + `.gitignore` stack is consulted. Git
725
+ * exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE / GIT_PREFIX into
726
+ * hook processes (a pre-commit hook from a linked worktree exports
727
+ * GIT_WORK_TREE), and those OVERRIDE cwd-based discovery; without the
728
+ * strip the probe would consult the outer repo instead of `appDir`.
729
+ * Same reasoning as the `vendor-gitignore` doctor check.
730
+ *
731
+ * @param {string} appDir
732
+ * @returns {boolean} true when `.webjs/vendor/importmap.json` is ignored
733
+ */
734
+ function vendorPinIsIgnored(appDir) {
735
+ try {
736
+ const {
737
+ GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
738
+ ...gitEnv
739
+ } = process.env;
740
+ const probe = `.webjs/vendor/${PIN_FILE}`;
741
+ // `git check-ignore -q` exits 0 when ignored, 1 when not ignored,
742
+ // 128 on error (not a git repo, etc.). Treat anything but 0 as
743
+ // "not ignored" so a non-git project never gets its .gitignore
744
+ // touched.
745
+ const result = spawnSync('git', ['check-ignore', '-q', probe], {
746
+ cwd: appDir,
747
+ stdio: 'pipe',
748
+ env: gitEnv,
749
+ });
750
+ return result.status === 0;
751
+ } catch {
752
+ return false;
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Make the `webjs vendor pin` output committable, idempotently.
758
+ *
759
+ * Vendoring is an OPTIONAL opt-in: the no-build default resolves bare
760
+ * specifiers at runtime via jspm.io and needs nothing committed. But a
761
+ * user who runs `webjs vendor pin` deliberately creates pins they want
762
+ * in source control, and a `.gitignore` that excludes `.webjs/` (the
763
+ * older scaffold pattern, or one an editor/agent "simplified") silently
764
+ * swallows that output. Fresh scaffolds already carry the vendor
765
+ * exception (a glob exclusion plus a re-include negation), so for them
766
+ * this is a no-op.
767
+ *
768
+ * Behaviour (only ever called from the opt-in `vendor pin` path):
769
+ * - The pin output is NOT ignored (the common, already-correct case):
770
+ * return `{ ignored: false, patched: false }`. Nothing is written.
771
+ * - The pin output IS ignored AND a `.gitignore` exists: heal it (see
772
+ * below), then re-probe. If the pin is committable afterwards return
773
+ * `{ ignored: true, patched: true, gitignorePath }`; if a broader
774
+ * unrelated rule still swallows it (e.g. a root `*.json`), the heal
775
+ * cannot help, so revert the edit and return `patched: false` so the
776
+ * caller prints a notice rather than claiming a fix that did not take.
777
+ * - The pin output IS ignored but there is NO `.gitignore` to patch
778
+ * (e.g. the ignore comes from a parent repo's `.gitignore`, or from
779
+ * `.git/info/exclude`): leave the tree untouched and return
780
+ * `{ ignored: true, patched: false, gitignorePath: null }` so the
781
+ * caller can print a notice instead of writing a file the user did
782
+ * not create.
783
+ *
784
+ * Healing has two parts, because a plain append is NOT enough. A bare
785
+ * directory exclusion (`.webjs/`, `/.webjs/`, `.webjs`, with or without a
786
+ * leading glob) excludes the directory itself, and git CANNOT re-include
787
+ * a child of an excluded directory, so any later negation is silently
788
+ * dead. So: (1) rewrite each such line IN PLACE to the glob form
789
+ * (`**` + `/.webjs/*`), which ignores the directory's CONTENTS at any
790
+ * depth while leaving the directory re-includable; (2) append whichever
791
+ * of the three exception lines are still missing. The heal is idempotent:
792
+ * a re-run finds the pin already committable and short-circuits.
793
+ *
794
+ * @param {string} appDir
795
+ * @returns {Promise<{ ignored: boolean, patched: boolean, gitignorePath: string | null }>}
796
+ */
797
+ export async function ensureVendorCommittable(appDir) {
798
+ if (!vendorPinIsIgnored(appDir)) {
799
+ return { ignored: false, patched: false, gitignorePath: null };
800
+ }
801
+ const gitignorePath = join(appDir, '.gitignore');
802
+ let original;
803
+ try {
804
+ original = await readFile(gitignorePath, 'utf8');
805
+ } catch {
806
+ // No app-level .gitignore to patch. The ignore is coming from a
807
+ // parent repo or from .git/info/exclude; do not fabricate a
808
+ // .gitignore the user never had. Let the caller print a notice.
809
+ return { ignored: true, patched: false, gitignorePath: null };
810
+ }
811
+
812
+ // The exclusion glob, assembled so the literal `*` + `/` sequence never
813
+ // appears in this file's source comments above.
814
+ const exclude = VENDOR_GITIGNORE_LINES[0]; // **/.webjs/*
815
+
816
+ // Preserve the file's line ending so a CRLF .gitignore stays all-CRLF
817
+ // (and an LF one all-LF). Splitting on bare `\n` keeps each existing
818
+ // line's trailing `\r`; the lines we WRITE (the rewritten exclusion and
819
+ // the appended block) must use the same ending or the file goes mixed.
820
+ // A file with any CRLF is treated as CRLF; otherwise LF.
821
+ const eol = /\r\n/.test(original) ? '\r\n' : '\n';
822
+
823
+ // 1. Rewrite any bare `.webjs` DIRECTORY exclusion to the glob form. A
824
+ // directory exclusion blocks all child negations, so it must become
825
+ // `**/.webjs/*` (ignore contents, keep the dir re-includable).
826
+ const lines = original.split('\n');
827
+ let rewroteDir = false;
828
+ const rewritten = lines.map((line) => {
829
+ // Trim CR too, so a CRLF file's `.webjs/\r` still matches.
830
+ const t = line.replace(/\r$/, '').trim();
831
+ // Match the bare-directory shapes only (no `/*` suffix, not already a
832
+ // negation): `.webjs`, `.webjs/`, `/.webjs`, `/.webjs/`, `**/.webjs`,
833
+ // `**/.webjs/`. These all exclude the directory itself. Emit the
834
+ // replacement with the file's own ending if the original line carried
835
+ // one (every line but a no-trailing-newline last line does).
836
+ if (/^(\*\*\/|\/)?\.webjs\/?$/.test(t)) {
837
+ rewroteDir = true;
838
+ return line.endsWith('\r') ? exclude + '\r' : exclude;
839
+ }
840
+ return line;
841
+ });
842
+
843
+ // 2. Append whichever exception lines are still missing.
844
+ const present = new Set(rewritten.map((l) => l.replace(/\r$/, '').trim()));
845
+ const missing = VENDOR_GITIGNORE_LINES.filter((l) => !present.has(l));
846
+
847
+ let next = rewritten.join('\n');
848
+ if (missing.length > 0) {
849
+ const block =
850
+ [
851
+ '# webjs: keep the committed vendor pin (`webjs vendor pin`) out of',
852
+ '# the `.webjs` cache exclusion so the pinned importmap is committable.',
853
+ ...missing,
854
+ ].join(eol) + eol;
855
+ const sep = next.endsWith('\n') || next === '' ? '' : eol;
856
+ next = next + sep + block;
857
+ }
858
+
859
+ if (!rewroteDir && missing.length === 0) {
860
+ // Nothing to change, yet git still ignores the pin: a broader,
861
+ // unrelated rule is the cause and the vendor exception cannot fix it.
862
+ return { ignored: true, patched: false, gitignorePath };
863
+ }
864
+
865
+ await writeFile(gitignorePath, next);
866
+
867
+ // 3. Re-probe. If a broader unrelated rule still swallows the pin, the
868
+ // edit did not achieve the goal, so revert it and report not-patched
869
+ // so the caller prints a notice instead of an inaccurate success.
870
+ if (vendorPinIsIgnored(appDir)) {
871
+ await writeFile(gitignorePath, original);
872
+ return { ignored: true, patched: false, gitignorePath };
873
+ }
874
+ return { ignored: true, patched: true, gitignorePath };
875
+ }
876
+
485
877
  /**
486
878
  * True when the app commits a vendor pin file (`.webjs/vendor/importmap.json`).
487
879
  * A pinned app's importmap is deterministic and cheap to read, so `dev.js`
@@ -839,9 +1231,20 @@ export async function pinAll(appDir, opts = {}) {
839
1231
  const expected = new Set([PIN_FILE]);
840
1232
  let downloaded = 0;
841
1233
 
1234
+ // Specs that were directly scanned (`partsByInstall`) AND the flattened
1235
+ // transitive specs the unified resolve returns (issue #446) must BOTH be
1236
+ // pinned, or a pinned app's importmap would be missing the transitive
1237
+ // entries the runtime live-resolve serves (e.g. `@codemirror/state` pulled
1238
+ // in by `@codemirror/lint`), breaking parity: the browser would hit an
1239
+ // unresolved-bare-specifier error for the transitive. For a transitive we
1240
+ // recover pkg + version + subpath by parsing the spec against the resolved
1241
+ // jspm URL (`derivePinParts`), since it has no `partsByInstall` entry.
1242
+ /** @type {Set<string>} */
1243
+ const pinnedDirectSpecs = new Set();
842
1244
  for (const [spec, jspmUrl] of Object.entries(resolved)) {
843
- const parts = partsByInstall.get(spec);
1245
+ const parts = partsByInstall.get(spec) || derivePinParts(spec, jspmUrl);
844
1246
  if (!parts) continue;
1247
+ const direct = partsByInstall.has(spec);
845
1248
  const { pkg, version, subpath } = parts;
846
1249
  if (download) {
847
1250
  const filename = bundleFilenameWithSubpath(pkg, version, subpath);
@@ -869,6 +1272,7 @@ export async function pinAll(appDir, opts = {}) {
869
1272
  );
870
1273
  pins.push({ pkg: spec, version, url: jspmUrl, integrity: sri || undefined });
871
1274
  }
1275
+ if (direct) pinnedDirectSpecs.add(spec);
872
1276
  }
873
1277
 
874
1278
  // If pin was attempted (installs non-empty) but resolved zero, do
@@ -878,11 +1282,16 @@ export async function pinAll(appDir, opts = {}) {
878
1282
  // every bare-specifier import. Better: surface the failure so the
879
1283
  // user knows pin didn't take, and let the next boot fall back to
880
1284
  // live API resolution (which may have recovered by then).
881
- if (installs.length > 0 && pins.length === 0) {
1285
+ //
1286
+ // Account on DIRECT specs only: pins also carries flattened transitive
1287
+ // entries (#446), so `pins.length === 0` would no longer mean "every
1288
+ // direct install failed". A resolve that returned only transitives but
1289
+ // no direct spec is still a total failure for the user's deps.
1290
+ if (installs.length > 0 && pinnedDirectSpecs.size === 0) {
882
1291
  return { pins, pruned: [], downloaded, failed: true, attemptedInstalls: installs, provider: from };
883
1292
  }
884
1293
 
885
- // Partial-failure surface. Some installs were attempted but not
1294
+ // Partial-failure surface. Some DIRECT installs were attempted but not
886
1295
  // every one made it into pins (jspm.io returned the package OK,
887
1296
  // but downloadBundle failed mid-stream in --download mode, or the
888
1297
  // resolver response was missing the package entirely). Write the
@@ -892,15 +1301,14 @@ export async function pinAll(appDir, opts = {}) {
892
1301
  // --download mode).
893
1302
  //
894
1303
  // Derive the missing set from partsByInstall (the bare-spec keys)
895
- // rather than from `installs` (the versioned strings). pins[].pkg
896
- // is the bare spec, so a direct filter over `installs` wouldn't
897
- // match anything.
898
- if (installs.length > pins.length) {
899
- const pinnedSpecs = new Set(pins.map(p => p.pkg));
1304
+ // rather than from `installs` (the versioned strings). Compare against
1305
+ // the DIRECT specs that pinned, NOT pins[].pkg (which now includes
1306
+ // transitives), so a transitive can't mask a missing direct dep.
1307
+ if (pinnedDirectSpecs.size < partsByInstall.size) {
900
1308
  /** @type {string[]} */
901
1309
  const missing = [];
902
1310
  for (const [spec, parts] of partsByInstall.entries()) {
903
- if (!pinnedSpecs.has(spec)) {
1311
+ if (!pinnedDirectSpecs.has(spec)) {
904
1312
  missing.push(`${parts.pkg}@${parts.version}${parts.subpath}`);
905
1313
  }
906
1314
  }
@@ -1361,6 +1769,341 @@ export function prunePinToReachable(imports, integrity, reachable) {
1361
1769
  return { imports: keptImports, integrity: keptIntegrity };
1362
1770
  }
1363
1771
 
1772
+ // ---------------------------------------------------------------------------
1773
+ // Importmap coherence check (issue #450)
1774
+ // ---------------------------------------------------------------------------
1775
+ //
1776
+ // A produced importmap pins one URL per resolved package, each URL carrying an
1777
+ // `@<version>`. That pinned graph is INCOHERENT when one resolved package
1778
+ // declares a dependency or peer range on ANOTHER resolved package and the
1779
+ // version actually pinned for that other package falls OUTSIDE the range. The
1780
+ // motivating crash (#446): `@codemirror/view@6.39.16` pinned while
1781
+ // `@codemirror/lint@6.9.6` (also pinned) needs `view@^6.42.0`, so a symbol
1782
+ // `lint` expects is missing from the older `view` bundle at runtime.
1783
+ //
1784
+ // This is a VALIDATION over a produced importmap, NOT a re-resolution (that is
1785
+ // #446's job) and NOT bundling. It emits warnings; it never mutates the map.
1786
+ //
1787
+ // PARITY: `checkImportmapCoherence` is a pure function of the EXTRACTED
1788
+ // `{ package -> pinned version }` set plus the dependency metadata. It does not
1789
+ // know or care whether the importmap came from a live jspm.io resolve or from a
1790
+ // committed `.webjs/vendor/importmap.json`. Two importmaps that pin the same
1791
+ // versions for the same packages therefore always produce the same verdict,
1792
+ // which is exactly the runtime-vs-vendored parity the maintainer requires.
1793
+
1794
+ /**
1795
+ * Extract `{ basePackage -> pinned version }` from an importmap's `imports`
1796
+ * map. Each value is a CDN URL (jspm.io's `npm:dayjs@1.11.13/...`, jsdelivr's
1797
+ * `npm/dayjs@1.11.13/...`, unpkg's bare `dayjs@1.11.13/...`, skypack's
1798
+ * `dayjs@1.11.13`) or a local `/__webjs/vendor/<pkg>@<version>...js` path. The
1799
+ * key is the bare package name parsed from the importmap KEY (the specifier),
1800
+ * which is authoritative; the version is parsed from the URL.
1801
+ *
1802
+ * A specifier that resolves to a version we cannot parse from its URL is
1803
+ * skipped (it contributes nothing to the dep graph rather than a wrong pin).
1804
+ * When the same base package appears at several versions (subpath imports),
1805
+ * the LAST parsed wins; in practice every subpath of a package resolves to the
1806
+ * one installed version, so they agree.
1807
+ *
1808
+ * @param {Record<string, string>} imports importmap `imports` map (specifier -> URL)
1809
+ * @returns {Map<string, string>} base package name -> pinned version
1810
+ */
1811
+ export function extractPinnedVersions(imports) {
1812
+ /** @type {Map<string, string>} */
1813
+ const out = new Map();
1814
+ for (const [spec, url] of Object.entries(imports || {})) {
1815
+ if (typeof url !== 'string') continue;
1816
+ const bare = extractPackageName(spec);
1817
+ if (!bare) continue;
1818
+ let version = null;
1819
+ if (url.startsWith('/__webjs/vendor/')) {
1820
+ // Local downloaded-pin path: `<name>@<version>[__subpath].js`. The name
1821
+ // is `--`-encoded for scoped packages; we only need the version, which
1822
+ // sits after the LAST `@` and before any `__subpath` / `.js` suffix.
1823
+ const filename = url.slice('/__webjs/vendor/'.length);
1824
+ const atIdx = filename.lastIndexOf('@');
1825
+ if (atIdx > 0) {
1826
+ const afterAt = filename.slice(atIdx + 1, filename.endsWith('.js') ? -3 : undefined);
1827
+ const subIdx = afterAt.indexOf('__');
1828
+ version = subIdx < 0 ? afterAt : afterAt.slice(0, subIdx);
1829
+ }
1830
+ } else {
1831
+ // CDN URL: find `<bare>@<version>` anchored on a non-name char (or the
1832
+ // string start) so a short name like `ms` does not false-match inside
1833
+ // another package's URL. Mirrors listPinned's parser.
1834
+ const escapedBare = bare.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1835
+ const m = new RegExp(`(?:^|[^a-zA-Z0-9_.-])${escapedBare}@([^/]+)`).exec(url);
1836
+ if (m) version = m[1];
1837
+ }
1838
+ if (version && /\d/.test(version)) out.set(bare, version);
1839
+ }
1840
+ return out;
1841
+ }
1842
+
1843
+ /**
1844
+ * Does `version` satisfy the npm `range`? PRAGMATIC, no semver dependency
1845
+ * (vendor.js stays dependency-free). Supports the shapes that appear in real
1846
+ * `dependencies` / `peerDependencies`: `*` / `latest` / `x` / `''` (any),
1847
+ * `||` alternation, a leading `>=` / `>` / `<=` / `<` / `=`, caret `^`, tilde
1848
+ * `~`, an `x`/`*` wildcard segment (`6.x`, `6.39.x`), and an exact `1.2.3`.
1849
+ *
1850
+ * Returns `true` / `false` when the range can be evaluated, and `null` when
1851
+ * the shape is one we do NOT statically understand (a URL range, a git range,
1852
+ * a hyphen `1.2.3 - 1.4.0` range). `null` is the "could not verify" signal: the
1853
+ * caller degrades to a soft "unverified" note rather than warning on a shape it
1854
+ * cannot judge. Failing open here is deliberate, a coherence check must never
1855
+ * cry wolf on a range it misread.
1856
+ *
1857
+ * Prerelease note: both the version and the range are judged on their release
1858
+ * line only (the `-beta` / `-rc` tag is dropped, see `parseSemver`), so a
1859
+ * prerelease pin is treated as its stable tuple. The worst case is a MISSED
1860
+ * warning when a prerelease is pinned, never a spurious one.
1861
+ *
1862
+ * @param {string} version e.g. `6.39.16`
1863
+ * @param {string} range e.g. `^6.42.0`
1864
+ * @returns {boolean | null}
1865
+ */
1866
+ export function satisfiesSemverRange(version, range) {
1867
+ const v = parseSemver(version);
1868
+ if (!v) return null;
1869
+ const r = String(range == null ? '' : range).trim();
1870
+ if (r === '' || r === '*' || r === 'x' || r === 'X' || r === 'latest') return true;
1871
+ if (r.startsWith('workspace:')) return true;
1872
+ // Alternation: satisfied if ANY clause is satisfied. A clause we cannot
1873
+ // evaluate (null) must not let a non-matching clause produce a false
1874
+ // negative, so an unknown clause makes the whole result unknown unless a
1875
+ // known clause already matched.
1876
+ if (r.includes('||')) {
1877
+ let sawUnknown = false;
1878
+ for (const clause of r.split('||')) {
1879
+ const res = satisfiesSemverRange(version, clause.trim());
1880
+ if (res === true) return true;
1881
+ if (res === null) sawUnknown = true;
1882
+ }
1883
+ return sawUnknown ? null : false;
1884
+ }
1885
+ // A space-joined comparator set (`>=6.0.0 <7.0.0`) must ALL be satisfied.
1886
+ if (/\s/.test(r)) {
1887
+ // A hyphen range (`1.2.3 - 1.4.0`) is not a comparator set; we do not
1888
+ // parse it, so degrade to unknown rather than mis-AND its halves.
1889
+ if (/\s-\s/.test(r)) return null;
1890
+ let result = true;
1891
+ for (const part of r.split(/\s+/)) {
1892
+ if (!part) continue;
1893
+ const res = satisfiesSemverRange(version, part);
1894
+ if (res === null) return null;
1895
+ if (res === false) result = false;
1896
+ }
1897
+ return result;
1898
+ }
1899
+ // Comparators: >= > <= < =.
1900
+ const cmpMatch = /^(>=|<=|>|<|=)\s*(.+)$/.exec(r);
1901
+ if (cmpMatch) {
1902
+ const op = cmpMatch[1];
1903
+ const bound = parseSemver(cmpMatch[2]);
1904
+ if (!bound) return null;
1905
+ const c = cmpSemver(v, bound);
1906
+ switch (op) {
1907
+ case '>=': return c >= 0;
1908
+ case '>': return c > 0;
1909
+ case '<=': return c <= 0;
1910
+ case '<': return c < 0;
1911
+ case '=': return c === 0;
1912
+ }
1913
+ }
1914
+ // Caret: same left-most non-zero segment. ^6.42.0 -> >=6.42.0 <7.0.0;
1915
+ // ^0.7.0 -> >=0.7.0 <0.8.0; ^0.0.3 -> >=0.0.3 <0.0.4.
1916
+ if (r.startsWith('^')) {
1917
+ const b = parseSemver(r.slice(1));
1918
+ if (!b) return null;
1919
+ if (cmpSemver(v, b) < 0) return false;
1920
+ if (b[0] > 0) return v[0] === b[0];
1921
+ if (b[1] > 0) return v[0] === 0 && v[1] === b[1];
1922
+ return v[0] === 0 && v[1] === 0 && v[2] === b[2];
1923
+ }
1924
+ // Tilde: ~6.42.0 -> >=6.42.0 <6.43.0; ~6 -> >=6.0.0 <7.0.0.
1925
+ if (r.startsWith('~')) {
1926
+ const raw = r.slice(1);
1927
+ const b = parseSemver(raw);
1928
+ if (!b) return null;
1929
+ if (cmpSemver(v, b) < 0) return false;
1930
+ // If the range named a minor (`~6.42` / `~6.42.0`), pin the minor; if it
1931
+ // named only a major (`~6`), pin the major.
1932
+ const namedMinor = /^\d+\.\d+/.test(raw);
1933
+ return namedMinor ? v[0] === b[0] && v[1] === b[1] : v[0] === b[0];
1934
+ }
1935
+ // x / * wildcard segment: 6.x, 6.39.x, 6.*.
1936
+ if (/^\d+(\.(\d+|[xX*])){0,2}$/.test(r) && /[xX*]/.test(r)) {
1937
+ const segs = r.split('.');
1938
+ for (let i = 0; i < segs.length; i++) {
1939
+ const s = segs[i];
1940
+ if (s === 'x' || s === 'X' || s === '*') break; // any beyond here
1941
+ if (Number(s) !== v[i]) return false;
1942
+ }
1943
+ return true;
1944
+ }
1945
+ // Exact `1.2.3` (or shorter `1` / `1.2`, treated as that prefix pinned). A
1946
+ // leading `v` (`v1.2.3`) is tolerated so a `v`-prefixed exact pin evaluates
1947
+ // instead of degrading to unverified.
1948
+ const exact = r.startsWith('v') ? r.slice(1) : r;
1949
+ if (/^\d+(\.\d+){0,2}$/.test(exact)) {
1950
+ const b = parseSemver(exact);
1951
+ if (!b) return null;
1952
+ const segs = exact.split('.').length;
1953
+ for (let i = 0; i < segs; i++) if (v[i] !== b[i]) return false;
1954
+ return true;
1955
+ }
1956
+ return null;
1957
+ }
1958
+
1959
+ /**
1960
+ * Parse a version string to a `[major, minor, patch]` numeric triple, or null
1961
+ * when it has no parseable numeric core (a `latest`, a git URL, a `*`).
1962
+ *
1963
+ * KNOWN LIMITATION: a prerelease / build suffix (`-rc.1`, `+sha`) is dropped, so
1964
+ * a version is judged purely on its release line. A pinned prerelease is treated
1965
+ * as its stable tuple: `6.42.0-beta.1` is judged as `6.42.0`, so a stable range
1966
+ * like `^6.42.0` reports it as a MATCH even though npm semver excludes a
1967
+ * prerelease from a stable range. This is a deliberate fail-safe simplification
1968
+ * (we do not carry prerelease ordering): the only consequence is a MISSED
1969
+ * coherence warning when a prerelease is pinned, never a spurious one on a
1970
+ * coherent graph. Pinned prereleases are vanishingly rare in a vendored
1971
+ * importmap, so the missed-warning risk is negligible.
1972
+ *
1973
+ * @param {string} v
1974
+ * @returns {[number, number, number] | null}
1975
+ */
1976
+ function parseSemver(v) {
1977
+ const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(v == null ? '' : v));
1978
+ if (!m) return null;
1979
+ return [Number(m[1]), Number(m[2] || 0), Number(m[3] || 0)];
1980
+ }
1981
+
1982
+ /**
1983
+ * Compare two `[major, minor, patch]` triples. Negative if a < b, 0 if equal,
1984
+ * positive if a > b.
1985
+ * @param {[number, number, number]} a
1986
+ * @param {[number, number, number]} b
1987
+ * @returns {number}
1988
+ */
1989
+ function cmpSemver(a, b) {
1990
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
1991
+ }
1992
+
1993
+ /**
1994
+ * @typedef {{
1995
+ * pkg: string,
1996
+ * version: string,
1997
+ * dependsOn: string,
1998
+ * kind: 'dependency' | 'peerDependency',
1999
+ * requiredRange: string,
2000
+ * pinnedVersion: string,
2001
+ * }} CoherenceConflict
2002
+ */
2003
+
2004
+ /**
2005
+ * @typedef {{
2006
+ * conflicts: CoherenceConflict[],
2007
+ * unverified: Array<{ pkg: string, reason: string }>,
2008
+ * checked: number,
2009
+ * }} CoherenceReport
2010
+ * `checked` counts the pinned packages whose dependency metadata was actually
2011
+ * read (so a clean verdict is grounded in real data); a package whose manifest
2012
+ * was unavailable is in `unverified` and is NOT counted as checked. `checked
2013
+ * === 0` with a non-empty `unverified` therefore means "could not verify
2014
+ * anything", which the caller surfaces as a soft degrade rather than "coherent".
2015
+ */
2016
+
2017
+ /**
2018
+ * Validate that a produced importmap's pinned dependency graph is COHERENT
2019
+ * (issue #450). For each resolved package, read its declared `dependencies`
2020
+ * and `peerDependencies` (via the injected `getManifest`) and, for every
2021
+ * declared range that targets ANOTHER package ALSO pinned in this importmap,
2022
+ * check the pinned version satisfies the range. A miss is a conflict naming
2023
+ * both packages, the required range, and the pinned version.
2024
+ *
2025
+ * This is the SAME function the doctor runs over the live importmap and over
2026
+ * `.webjs/vendor/importmap.json`. It is pure in `(imports, getManifest)`, so
2027
+ * the same pinned dep set yields the same verdict regardless of which input it
2028
+ * came from (the runtime-vs-vendored parity invariant).
2029
+ *
2030
+ * Degrades gracefully: a package whose manifest `getManifest` cannot supply
2031
+ * (not installed, unreadable, network unavailable) is recorded under
2032
+ * `unverified` and contributes NO conflict, so the check reports "could not
2033
+ * verify" rather than failing closed. A declared range in a shape we cannot
2034
+ * statically evaluate (see `satisfiesSemverRange` -> null) is likewise skipped,
2035
+ * never warned on.
2036
+ *
2037
+ * @param {Record<string, string>} imports importmap `imports` map
2038
+ * @param {{
2039
+ * getManifest: (pkg: string, version: string) =>
2040
+ * ({ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null
2041
+ * | Promise<{ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null>),
2042
+ * }} opts `getManifest` returns the declared dep ranges for a resolved
2043
+ * `pkg@version`, or null when unavailable (degrade to "unverified").
2044
+ * @returns {Promise<CoherenceReport>}
2045
+ */
2046
+ export async function checkImportmapCoherence(imports, opts) {
2047
+ const pinned = extractPinnedVersions(imports);
2048
+ /** @type {CoherenceConflict[]} */
2049
+ const conflicts = [];
2050
+ /** @type {Array<{ pkg: string, reason: string }>} */
2051
+ const unverified = [];
2052
+ // Sort for deterministic output: the same dep set always yields the same
2053
+ // ordering, which keeps the verdict (and any test asserting it) stable and
2054
+ // strengthens the parity guarantee end to end.
2055
+ const packages = [...pinned.keys()].sort();
2056
+ // Count of packages whose metadata we could actually read (so a conflict
2057
+ // verdict is grounded). A package whose manifest is unavailable lands in
2058
+ // `unverified` instead and does NOT count as checked, which lets the caller
2059
+ // distinguish "verified coherent" from "could not verify anything".
2060
+ let checked = 0;
2061
+ for (const pkg of packages) {
2062
+ const version = pinned.get(pkg);
2063
+ let manifest;
2064
+ try {
2065
+ manifest = await opts.getManifest(pkg, version);
2066
+ } catch {
2067
+ manifest = null;
2068
+ }
2069
+ if (!manifest || typeof manifest !== 'object') {
2070
+ unverified.push({ pkg, reason: `could not read dependency metadata for ${pkg}@${version}` });
2071
+ continue;
2072
+ }
2073
+ checked++;
2074
+ const groups = /** @type {const} */ ([
2075
+ ['dependency', manifest.dependencies],
2076
+ ['peerDependency', manifest.peerDependencies],
2077
+ ]);
2078
+ for (const [kind, deps] of groups) {
2079
+ if (!deps || typeof deps !== 'object') continue;
2080
+ for (const [depName, range] of Object.entries(deps)) {
2081
+ // Only edges INTO the pinned graph matter: a dep on a package that is
2082
+ // not in this importmap is not the importmap's coherence problem (it
2083
+ // is either bundled into a CDN megabundle or simply unused on the
2084
+ // client). Self-edges cannot conflict.
2085
+ if (depName === pkg) continue;
2086
+ const depPinned = pinned.get(depName);
2087
+ if (!depPinned) continue;
2088
+ const ok = satisfiesSemverRange(depPinned, String(range));
2089
+ if (ok === false) {
2090
+ conflicts.push({
2091
+ pkg,
2092
+ version: String(version),
2093
+ dependsOn: depName,
2094
+ kind,
2095
+ requiredRange: String(range),
2096
+ pinnedVersion: depPinned,
2097
+ });
2098
+ }
2099
+ // ok === null (range shape not understood) is silently skipped: the
2100
+ // check never warns on a range it could not evaluate.
2101
+ }
2102
+ }
2103
+ }
2104
+ return { conflicts, unverified, checked };
2105
+ }
2106
+
1364
2107
  /**
1365
2108
  * Per-process cache of SHA-384 integrity hashes for live-resolved vendor
1366
2109
  * URLs, keyed by the FINAL cross-origin URL. A vendor bundle at a given