@ontrails/trails 0.2.0

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 (121) hide show
  1. package/CHANGELOG.md +1906 -0
  2. package/README.md +48 -0
  3. package/bin/trails.ts +3 -0
  4. package/package.json +57 -0
  5. package/src/app.ts +167 -0
  6. package/src/clack.ts +111 -0
  7. package/src/cli.ts +308 -0
  8. package/src/completions.ts +431 -0
  9. package/src/lifecycle-source-io.ts +33 -0
  10. package/src/load-app-mirror.ts +202 -0
  11. package/src/local-state-io.ts +129 -0
  12. package/src/mcp-app.ts +42 -0
  13. package/src/mcp-options.ts +92 -0
  14. package/src/mcp.ts +8 -0
  15. package/src/project-writes.ts +377 -0
  16. package/src/regrade/audit.ts +571 -0
  17. package/src/regrade/config.ts +152 -0
  18. package/src/regrade/history.ts +636 -0
  19. package/src/regrade/lifecycle.ts +76 -0
  20. package/src/regrade/live-api-preserve.ts +123 -0
  21. package/src/regrade/plan-artifact.ts +515 -0
  22. package/src/regrade/plan-derivation.ts +301 -0
  23. package/src/regrade/prepared-run.ts +259 -0
  24. package/src/regrade/receipt-history.ts +446 -0
  25. package/src/regrade/source-transaction.ts +185 -0
  26. package/src/release/bindings.ts +58 -0
  27. package/src/release/changeset-packages.ts +99 -0
  28. package/src/release/check.ts +1191 -0
  29. package/src/release/cli-bundle.ts +575 -0
  30. package/src/release/config.ts +73 -0
  31. package/src/release/contract-facts.ts +425 -0
  32. package/src/release/homebrew.ts +221 -0
  33. package/src/release/index.ts +180 -0
  34. package/src/release/lock-roundtrip-smoke.ts +255 -0
  35. package/src/release/lock-roundtrip-workspace.ts +107 -0
  36. package/src/release/native-bun-publish.ts +964 -0
  37. package/src/release/native-bun-registry.ts +848 -0
  38. package/src/release/notes-cli.ts +171 -0
  39. package/src/release/notes.ts +390 -0
  40. package/src/release/pack-coherence.ts +455 -0
  41. package/src/release/package-route-facts.ts +146 -0
  42. package/src/release/packed-artifacts-smoke.ts +236 -0
  43. package/src/release/policy.ts +1780 -0
  44. package/src/release/semver.ts +104 -0
  45. package/src/release/smoke.ts +56 -0
  46. package/src/release/stable-version-release.ts +80 -0
  47. package/src/release/wayfinder-dogfood-smoke.ts +762 -0
  48. package/src/release/zero-line-transition.ts +68 -0
  49. package/src/retired-topo-command.ts +36 -0
  50. package/src/run-adapter-check.ts +76 -0
  51. package/src/run-argv.ts +133 -0
  52. package/src/run-collision.ts +126 -0
  53. package/src/run-completions-install.ts +179 -0
  54. package/src/run-example.ts +149 -0
  55. package/src/run-examples.ts +148 -0
  56. package/src/run-quiet.ts +75 -0
  57. package/src/run-regrade-progress.ts +47 -0
  58. package/src/run-release-check.ts +74 -0
  59. package/src/run-schema.ts +74 -0
  60. package/src/run-trace.ts +273 -0
  61. package/src/run-warden.ts +39 -0
  62. package/src/run-watch-project.ts +52 -0
  63. package/src/run-watch.ts +381 -0
  64. package/src/run-wayfind-outline.ts +170 -0
  65. package/src/scaffold-version-sync.ts +183 -0
  66. package/src/scaffold-versions.generated.ts +12 -0
  67. package/src/trails/adapter-check.ts +244 -0
  68. package/src/trails/add-surface.ts +816 -0
  69. package/src/trails/add-trail.ts +141 -0
  70. package/src/trails/add-verify.ts +252 -0
  71. package/src/trails/compile.ts +118 -0
  72. package/src/trails/completions-complete.ts +236 -0
  73. package/src/trails/completions.ts +47 -0
  74. package/src/trails/config-explain.ts +43 -0
  75. package/src/trails/create-adapter.ts +785 -0
  76. package/src/trails/create-scaffold.ts +1215 -0
  77. package/src/trails/create-versions.ts +62 -0
  78. package/src/trails/create.ts +652 -0
  79. package/src/trails/deprecate.ts +59 -0
  80. package/src/trails/dev-clean.ts +80 -0
  81. package/src/trails/dev-reset.ts +48 -0
  82. package/src/trails/dev-stats.ts +71 -0
  83. package/src/trails/dev-support.ts +360 -0
  84. package/src/trails/doctor.ts +77 -0
  85. package/src/trails/draft-promote.ts +949 -0
  86. package/src/trails/guide.ts +106 -0
  87. package/src/trails/load-app.ts +1145 -0
  88. package/src/trails/operator-context.ts +66 -0
  89. package/src/trails/project-context-output.ts +304 -0
  90. package/src/trails/project-context.ts +613 -0
  91. package/src/trails/project.ts +65 -0
  92. package/src/trails/regrade.ts +4951 -0
  93. package/src/trails/release-check.ts +113 -0
  94. package/src/trails/release-smoke.ts +49 -0
  95. package/src/trails/revise.ts +53 -0
  96. package/src/trails/root-dir.ts +21 -0
  97. package/src/trails/run-example.ts +592 -0
  98. package/src/trails/run-examples.ts +149 -0
  99. package/src/trails/run.ts +496 -0
  100. package/src/trails/scaffold-json.ts +60 -0
  101. package/src/trails/scaffold-topo-identity.ts +479 -0
  102. package/src/trails/survey.ts +990 -0
  103. package/src/trails/topo-activation.ts +14 -0
  104. package/src/trails/topo-constants.ts +2 -0
  105. package/src/trails/topo-history.ts +47 -0
  106. package/src/trails/topo-output-schemas.ts +259 -0
  107. package/src/trails/topo-pin.ts +38 -0
  108. package/src/trails/topo-read-support.ts +368 -0
  109. package/src/trails/topo-reports.ts +809 -0
  110. package/src/trails/topo-store-support.ts +323 -0
  111. package/src/trails/topo-support.ts +247 -0
  112. package/src/trails/topo-unpin.ts +61 -0
  113. package/src/trails/topo.ts +92 -0
  114. package/src/trails/validate.ts +348 -0
  115. package/src/trails/version-lifecycle-support.ts +936 -0
  116. package/src/trails/warden-guide.ts +134 -0
  117. package/src/trails/warden.ts +598 -0
  118. package/src/trails/wayfind-diff.ts +716 -0
  119. package/src/trails/wayfind-outline.ts +876 -0
  120. package/src/trails/wayfind.ts +1319 -0
  121. package/src/versions.ts +31 -0
@@ -0,0 +1,180 @@
1
+ export {
2
+ nativeBunPackBinding,
3
+ nativeBunReleaseBinding,
4
+ npmReleaseAdapterBinding,
5
+ releaseBindingCapabilityValues,
6
+ releaseBindingKindValues,
7
+ releaseBindingPlacementValues,
8
+ type ReleaseBindingCapability,
9
+ type ReleaseBindingDescriptor,
10
+ type ReleaseBindingKind,
11
+ type ReleaseBindingPlacement,
12
+ } from './bindings.js';
13
+ export {
14
+ checkReleaseRules,
15
+ discoverWorkspaces,
16
+ formatReleaseCheckReport,
17
+ loadReleaseConfig,
18
+ runReleaseCheck,
19
+ runReleaseCheckCli,
20
+ type ReleaseCheckReport,
21
+ type ReleaseCheckInput,
22
+ type ReleaseCheckResult,
23
+ type ReleaseConfigLoadResult,
24
+ type RunReleaseCheckOptions,
25
+ type WorkspaceInfo,
26
+ } from './check.js';
27
+ export {
28
+ createContractSourceSnapshots,
29
+ findPublicTrailContractChangeFacts,
30
+ findPublicTrailContractChangeFactsFromSnapshots,
31
+ type ContractReleaseFact,
32
+ type ContractReleaseFactAspect,
33
+ type ContractReleaseFactInput,
34
+ type ContractSourceSnapshot,
35
+ } from './contract-facts.js';
36
+ export {
37
+ findPackageRouteReleaseFacts,
38
+ type PackageRouteReleaseDiagnostic,
39
+ type PackageRouteReleaseFact,
40
+ } from './package-route-facts.js';
41
+ export {
42
+ defaultReleaseConfig,
43
+ defaultReleaseRules,
44
+ releaseConfigSchema,
45
+ releaseFactTypeSchema,
46
+ releaseFactTypeValues,
47
+ releaseIntentSourceSchema,
48
+ releaseIntentSourceValues,
49
+ releaseRuleSchema,
50
+ releaseRuleSeveritySchema,
51
+ releaseRuleSeverityValues,
52
+ type ReleaseConfig,
53
+ type ReleaseConfigInput,
54
+ type ReleaseFactType,
55
+ type ReleaseIntentSource,
56
+ type ReleaseRule,
57
+ type ReleaseRuleInput,
58
+ } from './config.js';
59
+ export {
60
+ buildTrailsCliReleaseArtifact,
61
+ createDeterministicTarGzip,
62
+ currentTrailsCliReleasePlatform,
63
+ expectedTrailsCliReleaseAssetNames,
64
+ runTrailsCliReleaseCli,
65
+ trailsCliArchiveName,
66
+ trailsCliChecksumName,
67
+ trailsCliReleasePlatforms,
68
+ trailsCliReleasePlatformValues,
69
+ verifyTrailsCliArchive,
70
+ type BuildTrailsCliReleaseOptions,
71
+ type TrailsCliReleaseArtifact,
72
+ type TrailsCliReleasePlatform,
73
+ type TrailsCliReleasePlatformDescriptor,
74
+ } from './cli-bundle.js';
75
+ export {
76
+ parseTrailsCliChecksums,
77
+ renderTrailsHomebrewFormula,
78
+ runTrailsHomebrewCli,
79
+ validateTrailsHomebrewRelease,
80
+ verifyTrailsHomebrewAssetDirectory,
81
+ type GitHubReleaseAsset,
82
+ type GitHubReleaseMetadata,
83
+ type TrailsCliReleaseChecksums,
84
+ } from './homebrew.js';
85
+ export {
86
+ createNpmPublishCommand,
87
+ findPackedFirstPartyDependencyMismatches,
88
+ publicationActionForRegistryState,
89
+ runNativeBunPublishCli,
90
+ trustedPublishingPreflightErrors,
91
+ unsupportedPublishLifecycleScripts,
92
+ type NativeBunPublishOptions,
93
+ type NativeBunPublishPackageJson,
94
+ type NativeBunPublishWorkspace,
95
+ } from './native-bun-publish.js';
96
+ export {
97
+ findLockfileWorkspaceMetadataMismatches,
98
+ isReleasePackCoherenceFile,
99
+ parseReleasePackCoherenceArgs,
100
+ runReleasePackCoherenceCli,
101
+ shouldRunReleasePackCoherenceCheck,
102
+ syncLockfileWorkspaceMetadataText,
103
+ type ReleasePackCoherenceLockfileInput,
104
+ type ReleasePackCoherenceLockfileSyncResult,
105
+ type ReleasePackCoherenceLockfileWorkspace,
106
+ type ReleasePackCoherenceInput,
107
+ type ReleasePackCoherenceParsedArgs,
108
+ type ReleasePackCoherenceWorkspace,
109
+ } from './pack-coherence.js';
110
+ export {
111
+ checkRegistryPosture,
112
+ classifyPackageRegistryState,
113
+ discoverRegistryWorkspaces,
114
+ factsFromRegistryResult,
115
+ formatDistTagSummary,
116
+ npmRegistryVersionView,
117
+ npmRegistryView,
118
+ registryPostureErrors,
119
+ runRegistryPreflight,
120
+ runRegistryPreflightCli,
121
+ type PackageRegistryFacts,
122
+ type PackageRegistryState,
123
+ type RegistryCheckPhase,
124
+ type RegistryPreflightOptions,
125
+ type RegistryResult,
126
+ type RegistryVersionView,
127
+ type RegistryView,
128
+ type RegistryWorkspace,
129
+ } from './native-bun-registry.js';
130
+ export {
131
+ collectReleaseNotesInput,
132
+ dedupeReleaseChanges,
133
+ extractChangelogEntry,
134
+ renderReleaseNotes,
135
+ type ReleaseNotesCollectOptions,
136
+ type ReleaseNotesChange,
137
+ type ReleaseNotesInput,
138
+ type ReleaseNotesPackageVersion,
139
+ type ReleaseNotesParsedChange,
140
+ } from './notes.js';
141
+ export { runReleaseNotesCli } from './notes-cli.js';
142
+ export {
143
+ channelIntentForDistTag,
144
+ evaluateReleasePolicy,
145
+ labelsForReleasePullRequest,
146
+ releaseIntentForVersionDelta,
147
+ runReleasePolicyCli,
148
+ type ChannelIntent,
149
+ type PublishIntent,
150
+ type ReleaseIntent,
151
+ type ReleasePolicyChangedFile,
152
+ type ReleasePolicyCommit,
153
+ type ReleasePolicyDecision,
154
+ type ReleasePolicyInput,
155
+ type ReleasePolicyPullRequest,
156
+ type ReleasePolicyRegistryPackage,
157
+ type ReleasePolicyReport,
158
+ type ReleasePolicySourcePullRequest,
159
+ type StackIntent,
160
+ } from './policy.js';
161
+ export {
162
+ releaseSmokeCheckValues,
163
+ runReleaseSmoke,
164
+ type ReleaseSmokeCheck,
165
+ type ReleaseSmokeCheckResult,
166
+ type ReleaseSmokeResult,
167
+ } from './smoke.js';
168
+ export {
169
+ runLockRoundtripSmoke,
170
+ type LockRoundtripSmokeOptions,
171
+ type LockRoundtripSmokeResult,
172
+ } from './lock-roundtrip-smoke.js';
173
+ export {
174
+ runPackedArtifactsSmoke,
175
+ type PackedArtifactsSmokeResult,
176
+ } from './packed-artifacts-smoke.js';
177
+ export {
178
+ runWayfinderDogfoodSmoke,
179
+ type WayfinderDogfoodSmokeResult,
180
+ } from './wayfinder-dogfood-smoke.js';
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Lock round-trip invariant (TRL-1200).
3
+ *
4
+ * For every committed `trails.lock` in the repo, a fresh `trails compile`
5
+ * against a cold per-user store followed by `trails validate` must be
6
+ * green, and the recompiled lock must be byte-identical to the committed
7
+ * one. Evidence the toolchain cannot reproduce does not merge.
8
+ *
9
+ * Failures name the divergence and the command that fixes it. Hand-editing
10
+ * a lock is never the remediation.
11
+ */
12
+
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { mkdtemp, rm } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
17
+
18
+ import { copyLockRoundtripWorkspace } from './lock-roundtrip-workspace.js';
19
+
20
+ const trailsBinFor = (repoRoot: string): string =>
21
+ join(repoRoot, 'apps/trails/bin/trails.ts');
22
+
23
+ export interface LockRoundtripSmokeResult {
24
+ readonly check: 'lock-roundtrip';
25
+ readonly lockCount: number;
26
+ readonly message: string;
27
+ readonly passed: true;
28
+ }
29
+
30
+ export interface LockRoundtripSmokeOptions {
31
+ /** Override lock discovery with explicit repo-relative lock paths. */
32
+ readonly lockPaths?: readonly string[];
33
+ readonly repoRoot?: string;
34
+ }
35
+
36
+ const discoverCommittedLocks = (repoRoot: string): readonly string[] => {
37
+ const result = Bun.spawnSync({
38
+ cmd: ['git', 'ls-files', '--', 'trails.lock', '*/trails.lock'],
39
+ cwd: repoRoot,
40
+ stderr: 'pipe',
41
+ stdout: 'pipe',
42
+ });
43
+ if (result.exitCode !== 0) {
44
+ throw new Error(
45
+ `lock-roundtrip: git ls-files failed: ${result.stderr.toString()}`
46
+ );
47
+ }
48
+ return result.stdout
49
+ .toString()
50
+ .split('\n')
51
+ .map((line) => line.trim())
52
+ .filter((line) => line.length > 0);
53
+ };
54
+
55
+ const resolveAppModule = (appDir: string): string => {
56
+ if (existsSync(join(appDir, 'src', 'app.ts'))) {
57
+ return './src/app.ts';
58
+ }
59
+ const packageJsonPath = join(appDir, 'package.json');
60
+ if (existsSync(packageJsonPath)) {
61
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
62
+ readonly main?: unknown;
63
+ };
64
+ if (typeof parsed.main === 'string') {
65
+ return parsed.main;
66
+ }
67
+ }
68
+ throw new Error(
69
+ `lock-roundtrip: unable to resolve the app module for "${appDir}" — expected src/app.ts or a package.json "main" entry.`
70
+ );
71
+ };
72
+
73
+ const runTrailsCommand = (
74
+ repoRoot: string,
75
+ stateHome: string,
76
+ args: readonly string[]
77
+ ): { readonly exitCode: number; readonly output: string } => {
78
+ const result = Bun.spawnSync({
79
+ cmd: [process.execPath, trailsBinFor(repoRoot), ...args],
80
+ cwd: repoRoot,
81
+ env: {
82
+ ...process.env,
83
+ NO_COLOR: '1',
84
+ TRAILS_STATE_HOME: stateHome,
85
+ XDG_STATE_HOME: stateHome,
86
+ } as Record<string, string | undefined>,
87
+ stderr: 'pipe',
88
+ stdout: 'pipe',
89
+ });
90
+ return {
91
+ exitCode: result.exitCode,
92
+ output: `${result.stdout.toString()}${result.stderr.toString()}`,
93
+ };
94
+ };
95
+
96
+ const summarizeEntryIds = (value: unknown): readonly string[] => {
97
+ if (typeof value !== 'object' || value === null) {
98
+ return [];
99
+ }
100
+ const { topoGraph } = value as {
101
+ readonly topoGraph?: { readonly entries?: readonly { id?: string }[] };
102
+ };
103
+ return (topoGraph?.entries ?? [])
104
+ .map((entry) => entry.id)
105
+ .filter((id): id is string => typeof id === 'string');
106
+ };
107
+
108
+ /** Name what diverged between the committed and recompiled lock bytes. */
109
+ const describeLockDivergence = (
110
+ committed: string,
111
+ recompiled: string
112
+ ): readonly string[] => {
113
+ const details: string[] = [];
114
+ let committedParsed: unknown;
115
+ let recompiledParsed: unknown;
116
+ try {
117
+ committedParsed = JSON.parse(committed);
118
+ recompiledParsed = JSON.parse(recompiled);
119
+ } catch {
120
+ return ['committed lock is not valid JSON'];
121
+ }
122
+ const committedLock = committedParsed as Record<string, unknown>;
123
+ const recompiledLock = recompiledParsed as Record<string, unknown>;
124
+
125
+ for (const section of ['scope', 'summary', 'topoGraphHash', 'version']) {
126
+ const left = JSON.stringify(committedLock[section]);
127
+ const right = JSON.stringify(recompiledLock[section]);
128
+ if (left !== right) {
129
+ details.push(`${section}: committed ${left} vs recompiled ${right}`);
130
+ }
131
+ }
132
+
133
+ const committedIds = new Set(summarizeEntryIds(committedParsed));
134
+ const recompiledIds = new Set(summarizeEntryIds(recompiledParsed));
135
+ const missing = [...committedIds].filter((id) => !recompiledIds.has(id));
136
+ const added = [...recompiledIds].filter((id) => !committedIds.has(id));
137
+ if (missing.length > 0) {
138
+ details.push(`entries only in committed lock: ${missing.join(', ')}`);
139
+ }
140
+ if (added.length > 0) {
141
+ details.push(`entries only in recompiled lock: ${added.join(', ')}`);
142
+ }
143
+ if (details.length === 0) {
144
+ details.push('topoGraph entry content differs (same ids, different facts)');
145
+ }
146
+ return details;
147
+ };
148
+
149
+ const checkSingleLock = async (
150
+ repoRoot: string,
151
+ lockPath: string
152
+ ): Promise<void> => {
153
+ const absoluteLockPath = resolve(repoRoot, lockPath);
154
+ const appDir = dirname(absoluteLockPath);
155
+ const relativeAppDir = relative(repoRoot, appDir) || '.';
156
+ const appModule = resolveAppModule(appDir);
157
+ const committedBytes = readFileSync(absoluteLockPath, 'utf8');
158
+ const stateHome = await mkdtemp(join(tmpdir(), 'lock-roundtrip-'));
159
+ const fixCommand = `bun apps/trails/bin/trails.ts compile --module ${appModule} --root-dir ${relativeAppDir} --permit '{"id":"lock-refresh","scopes":["topo:write"]}'`;
160
+ const remediation = `Fix: run \`${fixCommand}\` and commit the refreshed trails.lock. Never hand-edit the lock.`;
161
+
162
+ try {
163
+ const compileResult = runTrailsCommand(repoRoot, stateHome, [
164
+ 'compile',
165
+ '--module',
166
+ appModule,
167
+ '--root-dir',
168
+ relativeAppDir,
169
+ '--permit',
170
+ '{"id":"lock-roundtrip-gate","scopes":["topo:write"]}',
171
+ '--json',
172
+ ]);
173
+ if (compileResult.exitCode !== 0) {
174
+ throw new Error(
175
+ `lock-roundtrip: cold compile failed for ${lockPath}.\n${compileResult.output}\n${remediation}`
176
+ );
177
+ }
178
+
179
+ const validateResult = runTrailsCommand(repoRoot, stateHome, [
180
+ 'validate',
181
+ '--module',
182
+ appModule,
183
+ '--root-dir',
184
+ relativeAppDir,
185
+ '--json',
186
+ ]);
187
+ if (validateResult.exitCode !== 0) {
188
+ throw new Error(
189
+ `lock-roundtrip: validate failed for ${lockPath} after a cold recompile.\n${validateResult.output}\n${remediation}`
190
+ );
191
+ }
192
+
193
+ const recompiledBytes = readFileSync(absoluteLockPath, 'utf8');
194
+ if (recompiledBytes !== committedBytes) {
195
+ const details = describeLockDivergence(committedBytes, recompiledBytes);
196
+ throw new Error(
197
+ [
198
+ `lock-roundtrip: ${lockPath} is not byte-identical after a cold recompile.`,
199
+ ...details.map((detail) => ` - ${detail}`),
200
+ remediation,
201
+ ].join('\n')
202
+ );
203
+ }
204
+ } finally {
205
+ await rm(stateHome, { force: true, recursive: true });
206
+ }
207
+ };
208
+
209
+ export const runLockRoundtripSmoke = async (
210
+ options?: LockRoundtripSmokeOptions
211
+ ): Promise<LockRoundtripSmokeResult> => {
212
+ const repoRoot = resolve(options?.repoRoot ?? process.cwd());
213
+ const lockPaths = options?.lockPaths ?? discoverCommittedLocks(repoRoot);
214
+
215
+ for (const lockPath of lockPaths) {
216
+ const path = relative(repoRoot, resolve(repoRoot, lockPath));
217
+ if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) {
218
+ throw new Error(
219
+ `lock-roundtrip: lock path must stay inside repoRoot: ${lockPath}`
220
+ );
221
+ }
222
+ }
223
+ if (lockPaths.length > 0) {
224
+ const workspace = await mkdtemp(
225
+ join(tmpdir(), 'lock-roundtrip-workspace-')
226
+ );
227
+ try {
228
+ await copyLockRoundtripWorkspace(
229
+ repoRoot,
230
+ workspace,
231
+ lockPaths.map((path) => relative(repoRoot, resolve(repoRoot, path)))
232
+ );
233
+ for (const lockPath of lockPaths) {
234
+ await checkSingleLock(
235
+ workspace,
236
+ relative(repoRoot, resolve(repoRoot, lockPath))
237
+ );
238
+ }
239
+ } finally {
240
+ await rm(workspace, { force: true, recursive: true });
241
+ }
242
+ }
243
+
244
+ const message =
245
+ lockPaths.length === 0
246
+ ? 'lock-roundtrip: no committed trails.lock files found — nothing to verify'
247
+ : `lock-roundtrip: ${lockPaths.length} committed trails.lock file(s) recompiled cold — validate green, byte-identical (${lockPaths.join(', ')})`;
248
+
249
+ return {
250
+ check: 'lock-roundtrip',
251
+ lockCount: lockPaths.length,
252
+ message,
253
+ passed: true,
254
+ };
255
+ };
@@ -0,0 +1,107 @@
1
+ import {
2
+ cp,
3
+ lstat,
4
+ mkdir,
5
+ readFile,
6
+ readlink,
7
+ stat,
8
+ symlink,
9
+ writeFile,
10
+ } from 'node:fs/promises';
11
+ import { basename, dirname, relative, sep } from 'node:path';
12
+
13
+ const trackedInputPaths = (source: string): Set<string> => {
14
+ const result = Bun.spawnSync({
15
+ cmd: ['git', 'ls-files', '-z'],
16
+ cwd: source,
17
+ stderr: 'pipe',
18
+ stdout: 'pipe',
19
+ });
20
+ if (result.exitCode !== 0) {
21
+ throw new Error(
22
+ `lock-roundtrip: unable to list tracked inputs: ${result.stderr.toString()}`
23
+ );
24
+ }
25
+ const paths = new Set<string>();
26
+ for (const file of result.stdout.toString().split('\0').filter(Boolean)) {
27
+ let path = file.split('/').join(sep);
28
+ while (path !== '.') {
29
+ paths.add(path);
30
+ path = dirname(path);
31
+ }
32
+ }
33
+ return paths;
34
+ };
35
+
36
+ const isDisposablePath = (segments: readonly string[]): boolean =>
37
+ segments.some(
38
+ (part, index) =>
39
+ part === '.tmp-tests' ||
40
+ part === '.trails-tmp' ||
41
+ part === '.turbo' ||
42
+ (part === 'worktrees' && segments[index - 1] === '.claude') ||
43
+ (part === 'notes' && segments[index - 1] === '.agents')
44
+ );
45
+
46
+ /**
47
+ * Copy current working files for compile/validate without writing to their owner.
48
+ * Workspace dependency links resolve within the copy; only Bun's installed
49
+ * dependency store stays shared. Callers still need a stable tree while copying
50
+ * and must not modify installed dependencies during checks.
51
+ */
52
+ export const copyLockRoundtripWorkspace = async (
53
+ source: string,
54
+ destination: string,
55
+ lockPaths: readonly string[]
56
+ ): Promise<void> => {
57
+ const trackedPaths = trackedInputPaths(source);
58
+ const fixtureRoots = lockPaths
59
+ .filter((path) => isDisposablePath(path.split(sep)))
60
+ .map((path) => dirname(path));
61
+ await cp(source, destination, {
62
+ dereference: true,
63
+ filter: async (path, target) => {
64
+ const name = basename(path);
65
+ const relativePath = relative(source, path);
66
+ const segments = relativePath.split(sep);
67
+ const selectedFixture = fixtureRoots.some(
68
+ (root) =>
69
+ root === relativePath ||
70
+ root.startsWith(`${relativePath}${sep}`) ||
71
+ relativePath.startsWith(`${root}${sep}`)
72
+ );
73
+ if (
74
+ name === '.git' ||
75
+ (isDisposablePath(segments) &&
76
+ !selectedFixture &&
77
+ !trackedPaths.has(relativePath))
78
+ ) {
79
+ return false;
80
+ }
81
+ const entry = await lstat(path);
82
+ const inDependencies = segments.includes('node_modules');
83
+ if (inDependencies && entry.isSymbolicLink()) {
84
+ await mkdir(dirname(target), { recursive: true });
85
+ await symlink(await readlink(path), target);
86
+ return false;
87
+ }
88
+ if (name === '.bun' && basename(dirname(path)) === 'node_modules') {
89
+ await mkdir(dirname(target), { recursive: true });
90
+ await symlink(path, target, 'dir');
91
+ return false;
92
+ }
93
+ const materialized = entry.isSymbolicLink() ? await stat(path) : entry;
94
+ if (materialized.isFile()) {
95
+ // Bun's large-file copy can emit source metadata events on macOS.
96
+ // Explicit byte I/O keeps a guarded checkout free of copy side effects.
97
+ await mkdir(dirname(target), { recursive: true });
98
+ await writeFile(target, await readFile(path), {
99
+ mode: materialized.mode,
100
+ });
101
+ return false;
102
+ }
103
+ return true;
104
+ },
105
+ recursive: true,
106
+ });
107
+ };