@ontrails/trails 1.0.0-beta.23 → 1.0.0-beta.29

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 (48) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/README.md +6 -5
  3. package/package.json +16 -14
  4. package/src/app.ts +42 -3
  5. package/src/cli.ts +17 -3
  6. package/src/local-state-io.ts +20 -0
  7. package/src/mcp-app.ts +10 -0
  8. package/src/mcp-options.ts +2 -3
  9. package/src/project-writes.ts +11 -11
  10. package/src/release/check.ts +41 -25
  11. package/src/release/index.ts +40 -0
  12. package/src/release/native-bun-registry.ts +282 -19
  13. package/src/release/pack-coherence.ts +457 -0
  14. package/src/release/policy.ts +1684 -0
  15. package/src/release/semver.ts +104 -0
  16. package/src/release/wayfinder-dogfood-smoke.ts +600 -66
  17. package/src/run-completions-install.ts +2 -2
  18. package/src/run-schema.ts +41 -0
  19. package/src/run-wayfind-outline.ts +166 -0
  20. package/src/trails/adapter-check.ts +1 -1
  21. package/src/trails/add-surface.ts +1 -1
  22. package/src/trails/add-verify.ts +3 -3
  23. package/src/trails/compile.ts +17 -25
  24. package/src/trails/create-scaffold.ts +14 -14
  25. package/src/trails/create.ts +5 -5
  26. package/src/trails/dev-support.ts +44 -24
  27. package/src/trails/doctor.ts +23 -2
  28. package/src/trails/draft-promote.ts +7 -7
  29. package/src/trails/guide.ts +15 -19
  30. package/src/trails/load-app.ts +58 -9
  31. package/src/trails/operator-context.ts +66 -0
  32. package/src/trails/regrade.ts +72 -0
  33. package/src/trails/release-check.ts +1 -0
  34. package/src/trails/run-example.ts +21 -37
  35. package/src/trails/run-examples.ts +16 -32
  36. package/src/trails/run.ts +81 -57
  37. package/src/trails/survey.ts +76 -62
  38. package/src/trails/topo-output-schemas.ts +11 -0
  39. package/src/trails/topo-pin.ts +6 -20
  40. package/src/trails/topo-read-support.ts +91 -41
  41. package/src/trails/topo-reports.ts +4 -2
  42. package/src/trails/topo-store-support.ts +172 -39
  43. package/src/trails/topo-support.ts +1 -2
  44. package/src/trails/topo.ts +5 -19
  45. package/src/trails/validate.ts +9 -20
  46. package/src/trails/version-lifecycle-support.ts +10 -20
  47. package/src/trails/warden.ts +18 -0
  48. package/src/trails/wayfind.ts +1001 -0
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import { join, relative, resolve } from 'node:path';
4
- import { pathToFileURL } from 'node:url';
4
+
5
+ import { loadTrailsConfigValue } from '@ontrails/config';
5
6
 
6
7
  import { defaultReleaseConfig, releaseConfigSchema } from './config.js';
7
8
  import type { ReleaseConfigInput, ReleaseFactType } from './config.js';
@@ -34,6 +35,7 @@ export interface ReleaseCheckInput {
34
35
 
35
36
  export interface ReleaseCheckResult {
36
37
  readonly affectedPackages: readonly string[];
38
+ readonly activePackageChangesetsWithoutReleaseFacts: readonly string[];
37
39
  readonly changedChangesets: readonly string[];
38
40
  readonly contractFacts: readonly ContractReleaseFact[];
39
41
  readonly coveredPackages: readonly string[];
@@ -94,13 +96,6 @@ const VERSION_RELEASE_WORKSPACE_FILES = new Set([
94
96
  'CHANGELOG.md',
95
97
  'package.json',
96
98
  ]);
97
- const CONFIG_CANDIDATES = [
98
- 'trails.config.ts',
99
- 'trails.config.mts',
100
- 'trails.config.js',
101
- 'trails.config.mjs',
102
- ] as const;
103
-
104
99
  const normalizePath = (path: string): string =>
105
100
  path.replaceAll('\\', '/').replace(/^\.\//u, '');
106
101
 
@@ -315,6 +310,14 @@ const findChangedChangesetPaths = (
315
310
  .map(normalizePath)
316
311
  .filter((path) => CHANGESET_PATH_PATTERN.test(path));
317
312
 
313
+ const findActiveChangedChangesetPaths = (
314
+ changedFiles: readonly string[],
315
+ repoRoot: string
316
+ ): readonly string[] =>
317
+ findChangedChangesetPaths(changedFiles).filter((path) =>
318
+ existsSync(join(repoRoot, path))
319
+ );
320
+
318
321
  const findChangedChangesets = (
319
322
  changedChangesetPaths: readonly string[],
320
323
  repoRoot: string
@@ -387,6 +390,10 @@ export const checkReleaseRules = (
387
390
  input.workspaces
388
391
  );
389
392
  const changedChangesets = findChangedChangesetPaths(input.changedFiles);
393
+ const activeChangedChangesets = findActiveChangedChangesetPaths(
394
+ input.changedFiles,
395
+ input.repoRoot
396
+ );
390
397
  const changesets = findChangedChangesets(changedChangesets, input.repoRoot);
391
398
  const coveredPackages = [
392
399
  ...new Set(changesets.flatMap((changeset) => changeset.packages)),
@@ -403,6 +410,12 @@ export const checkReleaseRules = (
403
410
  const uncoveredContractFacts = contractFacts.filter(
404
411
  (fact) => !isContractFactCovered(fact, coveredPackages)
405
412
  );
413
+ const hasReleaseFacts =
414
+ affectedPackages.length > 0 || contractFacts.length > 0 || versionRelease;
415
+ const activePackageChangesetsWithoutReleaseFacts =
416
+ activeChangedChangesets.length > 0 && !hasReleaseFacts
417
+ ? activeChangedChangesets
418
+ : [];
406
419
  const matchedRuleIds = findMatchedRuleIds(input);
407
420
  const requiresPackageIntent = ruleMatchesFactType(input, 'package-content');
408
421
  const requiresPublicContractIntent = ruleMatchesFactType(
@@ -417,6 +430,12 @@ export const checkReleaseRules = (
417
430
  );
418
431
  }
419
432
 
433
+ if (activePackageChangesetsWithoutReleaseFacts.length > 0) {
434
+ errors.push(
435
+ `Active changesets require a matching package or release fact on this branch. Remove ${activePackageChangesetsWithoutReleaseFacts.join(', ')} or include the package-facing change here.`
436
+ );
437
+ }
438
+
420
439
  if (
421
440
  !noReleaseOverride &&
422
441
  !versionRelease &&
@@ -442,6 +461,7 @@ export const checkReleaseRules = (
442
461
  }
443
462
 
444
463
  return {
464
+ activePackageChangesetsWithoutReleaseFacts,
445
465
  affectedPackages,
446
466
  changedChangesets,
447
467
  contractFacts,
@@ -536,9 +556,7 @@ const findConfigPath = (
536
556
  return resolvedPath;
537
557
  }
538
558
 
539
- return CONFIG_CANDIDATES.map((entry) => resolve(repoRoot, entry)).find(
540
- (entry) => existsSync(entry)
541
- );
559
+ return undefined;
542
560
  };
543
561
 
544
562
  const extractReleaseConfig = (value: unknown): ReleaseConfigInput | undefined =>
@@ -546,14 +564,6 @@ const extractReleaseConfig = (value: unknown): ReleaseConfigInput | undefined =>
546
564
  ? (value['release'] as ReleaseConfigInput)
547
565
  : undefined;
548
566
 
549
- const importConfigModule = async (
550
- configPath: string
551
- ): Promise<Record<string, unknown>> => {
552
- const url = pathToFileURL(configPath);
553
- url.searchParams.set('t', Date.now().toString());
554
- return (await import(url.href)) as Record<string, unknown>;
555
- };
556
-
557
567
  export const loadReleaseConfig = async ({
558
568
  configPath,
559
569
  env = {},
@@ -564,13 +574,19 @@ export const loadReleaseConfig = async ({
564
574
  readonly repoRoot: string;
565
575
  }): Promise<ReleaseConfigLoadResult> => {
566
576
  const locatedConfigPath = findConfigPath(repoRoot, configPath);
567
- if (locatedConfigPath === undefined) {
577
+ if (configPath !== undefined && locatedConfigPath === undefined) {
568
578
  return {};
569
579
  }
570
580
 
571
581
  try {
572
- const mod = await importConfigModule(locatedConfigPath);
573
- const exported = mod['default'] ?? mod;
582
+ const loaded = await loadTrailsConfigValue({
583
+ configPath,
584
+ rootDir: repoRoot,
585
+ });
586
+ const exported = loaded.value;
587
+ if (exported === undefined) {
588
+ return {};
589
+ }
574
590
 
575
591
  if (isResolvableConfig(exported)) {
576
592
  const resolved = await exported.resolve({ cwd: repoRoot, env });
@@ -578,7 +594,7 @@ export const loadReleaseConfig = async ({
578
594
  if (resolved.isOk()) {
579
595
  return {
580
596
  config: extractReleaseConfig(resolved.value),
581
- configPath: locatedConfigPath,
597
+ configPath: loaded.configPath,
582
598
  };
583
599
  }
584
600
  throw new Error(
@@ -588,13 +604,13 @@ export const loadReleaseConfig = async ({
588
604
 
589
605
  return {
590
606
  config: extractReleaseConfig(resolved),
591
- configPath: locatedConfigPath,
607
+ configPath: loaded.configPath,
592
608
  };
593
609
  }
594
610
 
595
611
  return {
596
612
  config: extractReleaseConfig(exported),
597
- configPath: locatedConfigPath,
613
+ configPath: loaded.configPath,
598
614
  };
599
615
  } catch (error) {
600
616
  throw new Error(`Failed to load release config: ${errorMessage(error)}`, {
@@ -56,18 +56,58 @@ export {
56
56
  type NativeBunPublishPackageJson,
57
57
  type NativeBunPublishWorkspace,
58
58
  } from './native-bun-publish.js';
59
+ export {
60
+ findLockfileWorkspaceMetadataMismatches,
61
+ isReleasePackCoherenceFile,
62
+ parseReleasePackCoherenceArgs,
63
+ runReleasePackCoherenceCli,
64
+ shouldRunReleasePackCoherenceCheck,
65
+ syncLockfileWorkspaceMetadataText,
66
+ type ReleasePackCoherenceLockfileInput,
67
+ type ReleasePackCoherenceLockfileSyncResult,
68
+ type ReleasePackCoherenceLockfileWorkspace,
69
+ type ReleasePackCoherenceInput,
70
+ type ReleasePackCoherenceParsedArgs,
71
+ type ReleasePackCoherenceWorkspace,
72
+ } from './pack-coherence.js';
59
73
  export {
60
74
  checkRegistryPosture,
75
+ classifyPackageRegistryState,
61
76
  discoverRegistryWorkspaces,
62
77
  formatDistTagSummary,
78
+ npmRegistryVersionView,
79
+ npmRegistryView,
63
80
  registryPostureErrors,
64
81
  runRegistryPreflight,
65
82
  runRegistryPreflightCli,
83
+ type PackageRegistryFacts,
84
+ type PackageRegistryState,
85
+ type RegistryCheckPhase,
66
86
  type RegistryPreflightOptions,
67
87
  type RegistryResult,
88
+ type RegistryVersionView,
68
89
  type RegistryView,
69
90
  type RegistryWorkspace,
70
91
  } from './native-bun-registry.js';
92
+ export {
93
+ channelIntentForDistTag,
94
+ evaluateReleasePolicy,
95
+ labelsForReleasePullRequest,
96
+ releaseIntentForVersionDelta,
97
+ runReleasePolicyCli,
98
+ type ChannelIntent,
99
+ type PublishIntent,
100
+ type ReleaseIntent,
101
+ type ReleasePolicyChangedFile,
102
+ type ReleasePolicyCommit,
103
+ type ReleasePolicyDecision,
104
+ type ReleasePolicyInput,
105
+ type ReleasePolicyPullRequest,
106
+ type ReleasePolicyRegistryPackage,
107
+ type ReleasePolicyReport,
108
+ type ReleasePolicySourcePullRequest,
109
+ type StackIntent,
110
+ } from './policy.js';
71
111
  export {
72
112
  releaseSmokeCheckValues,
73
113
  runReleaseSmoke,
@@ -2,8 +2,15 @@
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import { join, relative, resolve } from 'node:path';
4
4
 
5
+ import { compareSemver } from './semver.js';
6
+
5
7
  const REPO_ROOT = resolve(process.cwd());
6
8
  const SUMMARY_DIST_TAGS = ['latest', 'beta'] as const;
9
+ /** Bound concurrent npm probes so release checks stay responsive on large workspaces. */
10
+ const PROBE_CONCURRENCY = 8;
11
+
12
+ /** Phase of a registry check: pre-publish readiness vs post-publish verification. */
13
+ export type RegistryCheckPhase = 'published' | 'ready';
7
14
 
8
15
  export interface RegistryPreflightOptions {
9
16
  readonly requirePublished: boolean;
@@ -35,6 +42,7 @@ export type RegistryResult =
35
42
  readonly name: string;
36
43
  readonly status: 'published';
37
44
  readonly version: string;
45
+ readonly versionPublished: boolean | undefined;
38
46
  readonly workspaceVersion: string;
39
47
  }
40
48
  | {
@@ -49,6 +57,106 @@ export type RegistryResult =
49
57
  readonly workspaceVersion: string;
50
58
  };
51
59
 
60
+ /**
61
+ * The single source of truth for what a package's registry state means for a
62
+ * release. Both the release policy engine and the registry preflight derive
63
+ * verdicts from this, so they cannot drift.
64
+ */
65
+ export type PackageRegistryState =
66
+ | { readonly kind: 'complete' }
67
+ | { readonly kind: 'needs-publish' }
68
+ | { readonly kind: 'first-time-package' }
69
+ | {
70
+ readonly kind: 'needs-tag-repair';
71
+ readonly currentTagVersion: string | undefined;
72
+ }
73
+ | { readonly kind: 'tag-points-ahead'; readonly currentTagVersion: string }
74
+ | { readonly kind: 'registry-inaccessible'; readonly error: string };
75
+
76
+ /** Minimal facts the classifier needs, mappable from any registry probe shape. */
77
+ export interface PackageRegistryFacts {
78
+ readonly status: 'inaccessible' | 'missing' | 'published';
79
+ readonly targetVersion: string;
80
+ readonly expectedTagVersion: string | undefined;
81
+ readonly versionPublished: boolean | undefined;
82
+ readonly error?: string | undefined;
83
+ }
84
+
85
+ /**
86
+ * Classify a package's registry state from two orthogonal facts — whether the
87
+ * target version is published, and where the dist-tag points relative to it —
88
+ * plus reachability. Members are mutually exclusive by construction.
89
+ *
90
+ * `versionPublished` is read strictly only in the behind-tag case: a behind tag
91
+ * becomes a `needs-tag-repair` only when the target is known published (`true`);
92
+ * `undefined` or `false` route to `needs-publish`, preserving the conservative
93
+ * release-policy behavior. When the tag already points at the target, an
94
+ * unprobed (`undefined`) state counts as published and yields `complete`, which
95
+ * matches the policy `versionPublished ?? true` default. `undefined` means the
96
+ * exact-version probe was not run, including policy inputs and compatibility
97
+ * callers that supply an injected registry view without a version probe.
98
+ */
99
+ export const classifyPackageRegistryState = (
100
+ facts: PackageRegistryFacts
101
+ ): PackageRegistryState => {
102
+ if (facts.status === 'inaccessible') {
103
+ return {
104
+ error: facts.error ?? 'registry probe failed',
105
+ kind: 'registry-inaccessible',
106
+ };
107
+ }
108
+ if (facts.status === 'missing') {
109
+ return { kind: 'first-time-package' };
110
+ }
111
+
112
+ const { expectedTagVersion, targetVersion, versionPublished } = facts;
113
+ const tagAtTarget = expectedTagVersion === targetVersion;
114
+ const tagAhead =
115
+ expectedTagVersion !== undefined &&
116
+ !tagAtTarget &&
117
+ compareSemver(expectedTagVersion, targetVersion) > 0;
118
+
119
+ if (tagAhead) {
120
+ return { currentTagVersion: expectedTagVersion, kind: 'tag-points-ahead' };
121
+ }
122
+ if (tagAtTarget) {
123
+ return versionPublished === false
124
+ ? { kind: 'needs-publish' }
125
+ : { kind: 'complete' };
126
+ }
127
+ if (versionPublished === true) {
128
+ return { currentTagVersion: expectedTagVersion, kind: 'needs-tag-repair' };
129
+ }
130
+ return { kind: 'needs-publish' };
131
+ };
132
+
133
+ /** Map a registry probe result into the classifier's fact shape. */
134
+ const factsFromResult = (result: RegistryResult): PackageRegistryFacts => {
135
+ if (result.status === 'published') {
136
+ return {
137
+ expectedTagVersion: result.expectedTagVersion,
138
+ status: 'published',
139
+ targetVersion: result.workspaceVersion,
140
+ versionPublished: result.versionPublished,
141
+ };
142
+ }
143
+ if (result.status === 'inaccessible') {
144
+ return {
145
+ error: result.error,
146
+ expectedTagVersion: undefined,
147
+ status: 'inaccessible',
148
+ targetVersion: result.workspaceVersion,
149
+ versionPublished: false,
150
+ };
151
+ }
152
+ return {
153
+ expectedTagVersion: undefined,
154
+ status: 'missing',
155
+ targetVersion: result.workspaceVersion,
156
+ versionPublished: false,
157
+ };
158
+ };
159
+
52
160
  const USAGE = `Usage: bun scripts/check-registry-preflight.ts [options]
53
161
 
54
162
  Read-only npm registry preflight for public @ontrails/* workspaces.
@@ -217,9 +325,66 @@ export const npmRegistryView: RegistryView = async (name) => {
217
325
  throw new Error(stderr.trim() || `npm view failed for ${name}`);
218
326
  };
219
327
 
328
+ /** Probe whether an exact `name@version` is published. The missing fact that
329
+ * a tag/version summary alone cannot answer. */
330
+ export type RegistryVersionView = (
331
+ name: string,
332
+ version: string
333
+ ) => Promise<boolean | undefined>;
334
+
335
+ const UNKNOWN_REGISTRY_VERSION_STATE: { readonly published?: boolean } = {};
336
+ const unknownRegistryVersionView: RegistryVersionView = async () =>
337
+ UNKNOWN_REGISTRY_VERSION_STATE.published;
338
+
339
+ export const npmRegistryVersionView: RegistryVersionView = async (
340
+ name,
341
+ version
342
+ ) => {
343
+ const proc = Bun.spawn(
344
+ ['npm', 'view', `${name}@${version}`, 'version', '--json'],
345
+ { stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' }
346
+ );
347
+ const [stdout, stderr, exitCode] = await Promise.all([
348
+ new Response(proc.stdout).text(),
349
+ new Response(proc.stderr).text(),
350
+ proc.exited,
351
+ ]);
352
+
353
+ if (exitCode === 0) {
354
+ return JSON.parse(stdout.trim()) === version;
355
+ }
356
+ const combined = `${stdout}\n${stderr}`;
357
+ if (combined.includes('E404') || combined.includes('404 Not Found')) {
358
+ return false;
359
+ }
360
+ throw new Error(stderr.trim() || `npm view failed for ${name}@${version}`);
361
+ };
362
+
363
+ /** Run async tasks with a bounded number in flight, preserving input order. */
364
+ const mapBounded = async <T, R>(
365
+ items: readonly T[],
366
+ limit: number,
367
+ task: (item: T) => Promise<R>
368
+ ): Promise<R[]> => {
369
+ const results: R[] = [];
370
+ let cursor = 0;
371
+ const worker = async (): Promise<void> => {
372
+ while (cursor < items.length) {
373
+ const index = cursor;
374
+ cursor += 1;
375
+ results[index] = await task(items[index] as T);
376
+ }
377
+ };
378
+ await Promise.all(
379
+ Array.from({ length: Math.min(limit, items.length) }, worker)
380
+ );
381
+ return results;
382
+ };
383
+
220
384
  const checkWorkspaceRegistryPosture = async (
221
385
  workspace: RegistryWorkspace,
222
386
  view: RegistryView,
387
+ versionView: RegistryVersionView,
223
388
  expectedTag: string
224
389
  ): Promise<RegistryResult> => {
225
390
  try {
@@ -238,6 +403,7 @@ const checkWorkspaceRegistryPosture = async (
238
403
  name: workspace.name,
239
404
  status: 'published',
240
405
  version: registry.version ?? '(unknown)',
406
+ versionPublished: await versionView(workspace.name, workspace.version),
241
407
  workspaceVersion: workspace.version,
242
408
  };
243
409
  } catch (error) {
@@ -250,33 +416,97 @@ const checkWorkspaceRegistryPosture = async (
250
416
  }
251
417
  };
252
418
 
253
- export const checkRegistryPosture = async (
419
+ type CheckRegistryPostureArgs =
420
+ | readonly [versionView: RegistryVersionView, expectedTag: string]
421
+ | readonly [expectedTag: string];
422
+
423
+ const normalizeCheckRegistryPostureArgs = (
424
+ args: CheckRegistryPostureArgs
425
+ ): {
426
+ readonly expectedTag: string;
427
+ readonly versionView: RegistryVersionView;
428
+ } => {
429
+ if (args.length === 1) {
430
+ return { expectedTag: args[0], versionView: unknownRegistryVersionView };
431
+ }
432
+ return { expectedTag: args[1], versionView: args[0] };
433
+ };
434
+
435
+ export function checkRegistryPosture(
254
436
  workspaces: readonly RegistryWorkspace[],
255
437
  view: RegistryView,
256
438
  expectedTag: string
257
- ): Promise<RegistryResult[]> =>
258
- Promise.all(
259
- workspaces.map((workspace) =>
260
- checkWorkspaceRegistryPosture(workspace, view, expectedTag)
261
- )
439
+ ): Promise<RegistryResult[]>;
440
+ export function checkRegistryPosture(
441
+ workspaces: readonly RegistryWorkspace[],
442
+ view: RegistryView,
443
+ versionView: RegistryVersionView,
444
+ expectedTag: string
445
+ ): Promise<RegistryResult[]>;
446
+ export async function checkRegistryPosture(
447
+ workspaces: readonly RegistryWorkspace[],
448
+ view: RegistryView,
449
+ ...args: CheckRegistryPostureArgs
450
+ ): Promise<RegistryResult[]> {
451
+ const { expectedTag, versionView } = normalizeCheckRegistryPostureArgs(args);
452
+ return mapBounded(workspaces, PROBE_CONCURRENCY, (workspace) =>
453
+ checkWorkspaceRegistryPosture(workspace, view, versionView, expectedTag)
262
454
  );
455
+ }
456
+
457
+ /**
458
+ * Phase-aware registry errors, derived from the shared classifier.
459
+ *
460
+ * `ready` (pre-publish): only a tag pointing *ahead* of the target or an
461
+ * inaccessible registry is an error. A behind tag or an unpublished target is
462
+ * the expected state before publish runs, not a failure.
463
+ *
464
+ * `published` (post-publish): every package must be `complete`.
465
+ */
466
+ const normalizeRegistryCheckPhase = (
467
+ phaseOrRequirePublished: boolean | RegistryCheckPhase
468
+ ): RegistryCheckPhase => {
469
+ if (typeof phaseOrRequirePublished !== 'boolean') {
470
+ return phaseOrRequirePublished;
471
+ }
472
+ return phaseOrRequirePublished ? 'published' : 'ready';
473
+ };
263
474
 
264
475
  export const registryPostureErrors = (
265
476
  results: readonly RegistryResult[],
266
477
  expectedTag: string,
267
- requirePublished: boolean
478
+ phaseOrRequirePublished: boolean | RegistryCheckPhase
268
479
  ): string[] => {
480
+ const phase = normalizeRegistryCheckPhase(phaseOrRequirePublished);
269
481
  const errors: string[] = [];
270
482
  for (const result of results) {
271
- if (result.status === 'inaccessible') {
272
- errors.push(`${result.name}: registry probe failed: ${result.error}`);
273
- } else if (result.status === 'missing') {
274
- if (requirePublished) {
275
- errors.push(`${result.name}: package is missing from the registry`);
276
- }
277
- } else if (result.expectedTagVersion !== result.workspaceVersion) {
483
+ const state = classifyPackageRegistryState(factsFromResult(result));
484
+ if (state.kind === 'registry-inaccessible') {
485
+ errors.push(`${result.name}: registry probe failed: ${state.error}`);
486
+ continue;
487
+ }
488
+ if (state.kind === 'tag-points-ahead') {
489
+ errors.push(
490
+ `${result.name}: dist-tag ${expectedTag} points to ${state.currentTagVersion}, which is newer than target ${result.workspaceVersion}`
491
+ );
492
+ continue;
493
+ }
494
+ if (phase === 'ready' || state.kind === 'complete') {
495
+ continue;
496
+ }
497
+ if (state.kind === 'first-time-package') {
498
+ errors.push(`${result.name}: package is missing from the registry`);
499
+ } else if (state.kind === 'needs-publish') {
500
+ const targetState =
501
+ result.status === 'published' && result.versionPublished === undefined
502
+ ? 'publish state was not probed'
503
+ : 'is not published';
278
504
  errors.push(
279
- `${result.name}: dist-tag ${expectedTag} points to ${result.expectedTagVersion ?? '(missing)'}, expected ${result.workspaceVersion}`
505
+ `${result.name}: target version ${result.workspaceVersion} ${targetState}`
506
+ );
507
+ } else if (state.kind === 'needs-tag-repair') {
508
+ errors.push(
509
+ `${result.name}: needs dist-tag update — ${expectedTag} points to ${state.currentTagVersion ?? '(missing)'}, target ${result.workspaceVersion}`
280
510
  );
281
511
  }
282
512
  }
@@ -290,6 +520,18 @@ export const formatDistTagSummary = (
290
520
  ', '
291
521
  );
292
522
 
523
+ const formatTargetVersionStatus = (
524
+ versionPublished: boolean | undefined
525
+ ): string => {
526
+ if (versionPublished === true) {
527
+ return 'target version published';
528
+ }
529
+ if (versionPublished === false) {
530
+ return 'target version not published yet';
531
+ }
532
+ return 'target version publish state unknown';
533
+ };
534
+
293
535
  const printResults = (
294
536
  results: readonly RegistryResult[],
295
537
  expectedTag: string
@@ -297,8 +539,9 @@ const printResults = (
297
539
  console.log(`Registry preflight for dist-tag "${expectedTag}"`);
298
540
  for (const result of results) {
299
541
  if (result.status === 'published') {
542
+ const targetStatus = formatTargetVersionStatus(result.versionPublished);
300
543
  console.log(
301
- `✓ ${result.name}@${result.workspaceVersion}: published (registry version ${result.version}, expected ${expectedTag}=${result.expectedTagVersion ?? 'missing'}, tags ${formatDistTagSummary(result.distTags)})`
544
+ `✓ ${result.name}@${result.workspaceVersion}: package exists, ${targetStatus} (registry version ${result.version}, expected ${expectedTag}=${result.expectedTagVersion ?? 'missing'}, tags ${formatDistTagSummary(result.distTags)})`
302
545
  );
303
546
  } else if (result.status === 'missing') {
304
547
  console.log(
@@ -310,18 +553,38 @@ const printResults = (
310
553
  }
311
554
  };
312
555
 
556
+ const normalizeRegistryPreflightViews = (
557
+ view: RegistryView | undefined,
558
+ versionView: RegistryVersionView | undefined
559
+ ): {
560
+ readonly versionView: RegistryVersionView;
561
+ readonly view: RegistryView;
562
+ } => {
563
+ if (view === undefined) {
564
+ return { versionView: npmRegistryVersionView, view: npmRegistryView };
565
+ }
566
+ return { versionView: versionView ?? unknownRegistryVersionView, view };
567
+ };
568
+
313
569
  export const runRegistryPreflight = async (
314
570
  options: RegistryPreflightOptions,
315
- view: RegistryView = npmRegistryView
571
+ view?: RegistryView,
572
+ versionView?: RegistryVersionView
316
573
  ): Promise<number> => {
574
+ const registryViews = normalizeRegistryPreflightViews(view, versionView);
317
575
  const expectedTag = options.tag ?? (await resolveDefaultTag());
318
576
  const workspaces = await discoverRegistryWorkspaces();
319
- const results = await checkRegistryPosture(workspaces, view, expectedTag);
577
+ const results = await checkRegistryPosture(
578
+ workspaces,
579
+ registryViews.view,
580
+ registryViews.versionView,
581
+ expectedTag
582
+ );
320
583
  printResults(results, expectedTag);
321
584
  const errors = registryPostureErrors(
322
585
  results,
323
586
  expectedTag,
324
- options.requirePublished
587
+ options.requirePublished ? 'published' : 'ready'
325
588
  );
326
589
  if (errors.length > 0) {
327
590
  console.error('\nRegistry preflight failed:');