@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,598 @@
1
+ /**
2
+ * `warden` trail -- Governance checks.
3
+ *
4
+ * Thin wrapper around the shared @ontrails/warden command surface.
5
+ */
6
+
7
+ import { posix } from 'node:path';
8
+
9
+ import { Result, trail, ValidationError } from '@ontrails/core';
10
+ import {
11
+ diagnosticSchema,
12
+ runWardenCommand,
13
+ wardenDepthValues,
14
+ wardenDraftsValues,
15
+ wardenFailOnValues,
16
+ wardenFormatValues,
17
+ wardenLockValues,
18
+ } from '@ontrails/warden';
19
+ import type {
20
+ EffectiveWardenConfig,
21
+ WardenExpectedAppBinding,
22
+ WardenTopoDriftResult,
23
+ } from '@ontrails/warden';
24
+ import { deriveWorkspaceView } from '@ontrails/topography';
25
+ import { z } from 'zod';
26
+
27
+ import {
28
+ assertObservableProjectApps,
29
+ resolveOperatorProjectContext,
30
+ } from './project-context.js';
31
+ import type {
32
+ OperatorProjectApp,
33
+ OperatorProjectContext,
34
+ } from './project-context.js';
35
+ import {
36
+ operatorProjectContextOutput,
37
+ operatorProjectContextOutputSchema,
38
+ } from './project-context-output.js';
39
+ import type { OperatorProjectContextOutput } from './project-context-output.js';
40
+ import { markCurrentAppExampleInput } from './topo-support.js';
41
+
42
+ const wardenWorkspaceAppEvidenceSchema = z.object({
43
+ appId: z.string(),
44
+ appRoot: z.string(),
45
+ artifactPath: z.string(),
46
+ binding: z.enum(['matched', 'unobserved']),
47
+ blockedReason: z.string().optional(),
48
+ committedHash: z.string().nullable().optional(),
49
+ currentHash: z.string().optional(),
50
+ freshness: z.enum(['fresh', 'stale', 'unavailable', 'unobserved']),
51
+ modulePath: z.string(),
52
+ moduleSource: z.enum(['config', 'convention', 'module']),
53
+ provenance: z.literal('warden-app-drift'),
54
+ status: z.enum(['available', 'missing', 'unobserved']),
55
+ });
56
+
57
+ const wardenWorkspaceEvidenceSchema = z.object({
58
+ apps: z.array(wardenWorkspaceAppEvidenceSchema).readonly(),
59
+ completeness: z.enum(['complete', 'partial']),
60
+ });
61
+
62
+ type WardenWorkspaceEvidence = z.output<typeof wardenWorkspaceEvidenceSchema>;
63
+
64
+ const assertWardenArtifactBindings = async (
65
+ context: OperatorProjectContext
66
+ ): Promise<Result<void, ValidationError>> => {
67
+ if (context.identity.workspace === undefined) {
68
+ return Result.ok();
69
+ }
70
+ const selectedAppIds =
71
+ context.selectedExtent === 'workspace'
72
+ ? context.apps.map((app) => app.id as string)
73
+ : [context.app.id as string];
74
+ try {
75
+ const view = await deriveWorkspaceView({
76
+ identity: context.identity,
77
+ selectedAppIds,
78
+ });
79
+ const invalid = view.evidence.apps.filter(
80
+ (app) =>
81
+ selectedAppIds.includes(app.id) &&
82
+ (app.binding === 'mismatched' || app.status === 'invalid')
83
+ );
84
+ return invalid.length === 0
85
+ ? Result.ok()
86
+ : Result.err(
87
+ new ValidationError(
88
+ `Saved artifact evidence is invalid for Config-owned app selection: ${invalid.map((app) => app.id).join(', ')}.`,
89
+ {
90
+ context: {
91
+ appIds: selectedAppIds,
92
+ evidence: invalid,
93
+ projectRoot: context.projectRoot,
94
+ reason: 'invalid-binding',
95
+ },
96
+ }
97
+ )
98
+ );
99
+ } catch (error) {
100
+ return Result.err(
101
+ new ValidationError(
102
+ 'Unable to inspect saved artifact identity before Warden execution.',
103
+ {
104
+ ...(error instanceof Error ? { cause: error } : {}),
105
+ context: {
106
+ appIds: selectedAppIds,
107
+ detail: error instanceof Error ? error.message : String(error),
108
+ projectRoot: context.projectRoot,
109
+ reason: 'invalid-binding',
110
+ },
111
+ }
112
+ )
113
+ );
114
+ }
115
+ };
116
+
117
+ const assertWardenArtifactPreflight = async (
118
+ context: OperatorProjectContext
119
+ ): Promise<Result<void, ValidationError>> => {
120
+ const observable = await assertObservableProjectApps(context);
121
+ return observable.isErr()
122
+ ? observable
123
+ : await assertWardenArtifactBindings(context);
124
+ };
125
+
126
+ /**
127
+ * Report whether a resolved Warden run can reach saved lock evidence.
128
+ *
129
+ * Deliberately over-approximates the shared CLI drift gate (`shouldRunDrift`
130
+ * in `packages/warden/src/cli.ts`, which on this path only fires at depth
131
+ * `all` with the lock not skipped): lock skipping opts out entirely, and both
132
+ * topo-aware depths otherwise preflight so no lock-reading depth can drift out
133
+ * of coverage. Fixing is not an exemption — a `--fix` run at the default depth
134
+ * still reports drift evidence, so it needs the same artifact preflight as a
135
+ * reporting run.
136
+ */
137
+ const consumesSavedArtifactEvidence = (
138
+ effectiveConfig: EffectiveWardenConfig | undefined
139
+ ): boolean => {
140
+ if (effectiveConfig === undefined) {
141
+ return true;
142
+ }
143
+ if (effectiveConfig.lock === 'skip') {
144
+ return false;
145
+ }
146
+ return effectiveConfig.depth === 'topo' || effectiveConfig.depth === 'all';
147
+ };
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Trail definition
151
+ // ---------------------------------------------------------------------------
152
+
153
+ const wardenInputSchema = z
154
+ .object({
155
+ adapterCheck: z
156
+ .boolean()
157
+ .default(false)
158
+ .describe('Run shared adapter authoring checks'),
159
+ app: z.string().optional().describe('Configured workspace app ID'),
160
+ apps: z
161
+ .array(z.string())
162
+ .optional()
163
+ .describe('Legacy app names or module paths to govern'),
164
+ cached: z.boolean().default(false).describe('Alias for --lock cached'),
165
+ ci: z.boolean().default(false).describe('Use the CI Warden preset'),
166
+ configPath: z.string().optional().describe('Path to trails.config.ts'),
167
+ depth: z
168
+ .enum(wardenDepthValues)
169
+ .optional()
170
+ .describe('Cumulative analysis depth'),
171
+ drafts: z.enum(wardenDraftsValues).optional().describe('Draft state mode'),
172
+ excludeDrafts: z
173
+ .boolean()
174
+ .default(false)
175
+ .describe('Alias for --drafts exclude'),
176
+ failOn: z.enum(wardenFailOnValues).optional().describe('Failure threshold'),
177
+ fix: z.boolean().default(false).describe('Apply safe source fixes'),
178
+ format: z.enum(wardenFormatValues).optional().describe('Output format'),
179
+ github: z.boolean().default(false).describe('Alias for --format github'),
180
+ includeDrafts: z
181
+ .boolean()
182
+ .default(false)
183
+ .describe('Alias for --drafts include'),
184
+ json: z.boolean().default(false).describe('Alias for --format json'),
185
+ lock: z.enum(wardenLockValues).optional().describe('Lockfile mode'),
186
+ noLockMutation: z
187
+ .boolean()
188
+ .default(false)
189
+ .describe('Suppress lockfile mutation'),
190
+ onlyDrafts: z.boolean().default(false).describe('Alias for --drafts only'),
191
+ prePush: z.boolean().default(false).describe('Use the pre-push preset'),
192
+ refresh: z.boolean().default(false).describe('Alias for --lock refresh'),
193
+ rootDir: z.string().optional().describe('Root directory to scan'),
194
+ scopeExclude: z
195
+ .array(z.string())
196
+ .optional()
197
+ .describe('Root-relative path globs that Warden should not govern'),
198
+ skipLock: z.boolean().default(false).describe('Alias for --lock skip'),
199
+ strict: z.boolean().default(false).describe('Alias for --fail-on warning'),
200
+ summary: z.boolean().default(false).describe('Alias for --format summary'),
201
+ })
202
+ .refine((input) => input.app === undefined || input.apps === undefined, {
203
+ message:
204
+ 'Use --app for Config-owned app selection; do not combine it with legacy --apps.',
205
+ path: ['app'],
206
+ });
207
+
208
+ type WardenTrailInput = z.infer<typeof wardenInputSchema>;
209
+
210
+ /**
211
+ * Report whether an authored input pins lock skipping through any alias.
212
+ *
213
+ * Mirrors the lock alias precedence in {@link buildWardenCommandArgs}, where
214
+ * `--skip-lock` outranks `--refresh` and `--cached`, which in turn outrank an
215
+ * explicit `--lock` value.
216
+ */
217
+ const inputSkipsLockEvidence = (input: WardenTrailInput): boolean => {
218
+ if (input.skipLock === true) {
219
+ return true;
220
+ }
221
+ if (input.refresh === true || input.cached === true) {
222
+ return false;
223
+ }
224
+ return input.lock === 'skip';
225
+ };
226
+
227
+ /**
228
+ * Report whether a fixing run must preflight saved artifacts before Warden
229
+ * writes any patched source file.
230
+ *
231
+ * The shared runner applies safe fixes inside `runWarden` itself
232
+ * (`applySafeFixesToFiles` in `packages/warden/src/cli.ts`), so the post-run
233
+ * {@link consumesSavedArtifactEvidence} gate cannot protect source: by the time
234
+ * `report.effectiveConfig` exists the files have already been rewritten, and
235
+ * those fixes were selected against the very topo evidence the gate validates.
236
+ * This input-level predicate therefore runs before the command and deliberately
237
+ * over-approximates the post-run gate: it exempts only an input that explicitly
238
+ * pins both lock skipping and `source` depth. Presets cannot widen that
239
+ * exemption -- `parseWardenCommandArgs` applies `--pre-push`/`--ci` in a first
240
+ * token pass and explicit options in a second, so an explicit `--depth source`
241
+ * always wins -- and the CLI layer outranks the config and environment layers
242
+ * in `resolveWardenConfig`. Every other fixing input preflights, including
243
+ * inputs whose resolved depth would never have read a lock.
244
+ *
245
+ * Moving this gate to its single owner -- a preflight hook inside `runWarden`,
246
+ * so the shared runner validates evidence before its own writes -- is tracked
247
+ * as TRL-1322.
248
+ */
249
+ const fixRunNeedsArtifactPreflight = (input: WardenTrailInput): boolean =>
250
+ input.fix === true &&
251
+ !(inputSkipsLockEvidence(input) && input.depth === 'source');
252
+
253
+ const pushFlag = (args: string[], condition: boolean, flag: string): void => {
254
+ if (condition) {
255
+ args.push(flag);
256
+ }
257
+ };
258
+
259
+ const pushValue = (
260
+ args: string[],
261
+ flag: string,
262
+ value: string | undefined
263
+ ): void => {
264
+ if (value !== undefined) {
265
+ args.push(flag, value);
266
+ }
267
+ };
268
+
269
+ const pushApps = (
270
+ args: string[],
271
+ apps: readonly string[] | undefined
272
+ ): void => {
273
+ if (apps !== undefined && apps.length > 0) {
274
+ args.push('--apps', apps.join(','));
275
+ }
276
+ };
277
+
278
+ const pushRepeatedValues = (
279
+ args: string[],
280
+ flag: string,
281
+ values: readonly string[] | undefined
282
+ ): void => {
283
+ for (const value of values ?? []) {
284
+ args.push(flag, value);
285
+ }
286
+ };
287
+
288
+ export const buildWardenCommandArgs = (
289
+ input: WardenTrailInput
290
+ ): readonly string[] => {
291
+ const args: string[] = [];
292
+
293
+ pushFlag(args, input.prePush, '--pre-push');
294
+ pushFlag(args, input.ci, '--ci');
295
+ pushValue(args, '--depth', input.depth);
296
+ if (input.strict) {
297
+ args.push('--strict');
298
+ } else {
299
+ pushValue(args, '--fail-on', input.failOn);
300
+ }
301
+ if (input.github) {
302
+ args.push('--github');
303
+ } else if (input.json) {
304
+ args.push('--json');
305
+ } else if (input.summary) {
306
+ args.push('--summary');
307
+ } else {
308
+ pushValue(args, '--format', input.format);
309
+ }
310
+ if (input.skipLock) {
311
+ args.push('--skip-lock');
312
+ } else if (input.refresh) {
313
+ args.push('--refresh');
314
+ } else if (input.cached) {
315
+ args.push('--cached');
316
+ } else {
317
+ pushValue(args, '--lock', input.lock);
318
+ }
319
+ if (input.onlyDrafts) {
320
+ args.push('--only-drafts');
321
+ } else if (input.excludeDrafts) {
322
+ args.push('--exclude-drafts');
323
+ } else if (input.includeDrafts) {
324
+ args.push('--include-drafts');
325
+ } else {
326
+ pushValue(args, '--drafts', input.drafts);
327
+ }
328
+ pushFlag(args, input.noLockMutation, '--no-lock-mutation');
329
+ pushFlag(args, input.fix, '--fix');
330
+ pushFlag(args, input.adapterCheck, '--adapter-check');
331
+ pushValue(args, '--config-path', input.configPath);
332
+ pushRepeatedValues(args, '--scope-exclude', input.scopeExclude);
333
+ pushApps(args, input.apps);
334
+
335
+ return args;
336
+ };
337
+
338
+ const projectRelativeModulePath = (app: OperatorProjectApp): string =>
339
+ app.configured ? posix.join(app.root, app.modulePath) : app.modulePath;
340
+
341
+ interface SelectedWardenApps {
342
+ readonly apps: readonly string[] | undefined;
343
+ readonly expectedAppBindings: readonly WardenExpectedAppBinding[] | undefined;
344
+ }
345
+
346
+ const selectedWardenApps = (
347
+ context: OperatorProjectContext,
348
+ legacyApps: readonly string[] | undefined
349
+ ): Result<SelectedWardenApps, ValidationError> => {
350
+ if (context.identity.workspace === undefined) {
351
+ return Result.ok({
352
+ apps: legacyApps,
353
+ expectedAppBindings: undefined,
354
+ });
355
+ }
356
+ if (legacyApps !== undefined) {
357
+ return Result.err(
358
+ new ValidationError(
359
+ 'Configured workspaces derive Warden app targets from workspace.apps. Use --app <id> to narrow one invocation; legacy --apps is not a second app catalog.',
360
+ {
361
+ context: {
362
+ configuredAppIds: context.identity.apps.map((app) => app.id),
363
+ reason: 'invalid-binding',
364
+ },
365
+ }
366
+ )
367
+ );
368
+ }
369
+ const selectedApps =
370
+ context.selectedExtent === 'workspace' ? context.apps : [context.app];
371
+ const bindings: WardenExpectedAppBinding[] = [];
372
+ for (const app of selectedApps) {
373
+ if (app.id === undefined) {
374
+ return Result.err(
375
+ new ValidationError(
376
+ 'Configured Warden app selection is missing its Config-owned identity.',
377
+ { context: { appRoot: app.root, reason: 'invalid-binding' } }
378
+ )
379
+ );
380
+ }
381
+ bindings.push({
382
+ app: projectRelativeModulePath(app),
383
+ expectedAppId: app.id,
384
+ rootDir: app.rootDir,
385
+ });
386
+ }
387
+ return Result.ok({
388
+ apps: bindings.map((binding) => binding.app),
389
+ expectedAppBindings: bindings,
390
+ });
391
+ };
392
+
393
+ const renderWardenSelection = (
394
+ formatted: string,
395
+ format: 'github' | 'json' | 'summary',
396
+ project: OperatorProjectContextOutput,
397
+ workspaceEvidence?: WardenWorkspaceEvidence | undefined
398
+ ): Result<string, ValidationError> => {
399
+ if (format === 'json') {
400
+ try {
401
+ const parsed = JSON.parse(formatted) as Record<string, unknown>;
402
+ return Result.ok(
403
+ JSON.stringify({
404
+ ...parsed,
405
+ project,
406
+ ...(workspaceEvidence === undefined ? {} : { workspaceEvidence }),
407
+ })
408
+ );
409
+ } catch (error) {
410
+ return Result.err(
411
+ new ValidationError(
412
+ 'Warden returned invalid JSON output.',
413
+ error instanceof Error ? { cause: error } : {}
414
+ )
415
+ );
416
+ }
417
+ }
418
+ const selection = `Selection: ${project.selectedExtent} (${project.selectionProvenance}) at ${project.projectRoot}`;
419
+ return Result.ok(
420
+ formatted.length === 0 ? selection : `${selection}\n${formatted}`
421
+ );
422
+ };
423
+
424
+ const deriveWardenWorkspaceEvidence = (
425
+ context: OperatorProjectContext,
426
+ topoDrift: readonly WardenTopoDriftResult[] | undefined
427
+ ): WardenWorkspaceEvidence | undefined => {
428
+ if (context.selectedExtent !== 'workspace') {
429
+ return undefined;
430
+ }
431
+ const observations = new Map(
432
+ (topoDrift ?? []).map((entry) => [entry.name, entry.drift])
433
+ );
434
+ const apps = context.apps.map((app) => {
435
+ const drift = observations.get(app.id as string);
436
+ let status: WardenWorkspaceEvidence['apps'][number]['status'];
437
+ let freshness: WardenWorkspaceEvidence['apps'][number]['freshness'];
438
+ if (drift === undefined) {
439
+ status = 'unobserved';
440
+ freshness = 'unobserved';
441
+ } else if (drift.committedHash === null) {
442
+ status = 'missing';
443
+ freshness = 'unavailable';
444
+ } else {
445
+ status = 'available';
446
+ freshness = drift.stale ? 'stale' : 'fresh';
447
+ }
448
+ return {
449
+ appId: app.id as string,
450
+ appRoot: app.root,
451
+ artifactPath: app.lockPath,
452
+ binding:
453
+ drift === undefined ? ('unobserved' as const) : ('matched' as const),
454
+ ...(drift?.blockedReason === undefined
455
+ ? {}
456
+ : { blockedReason: drift.blockedReason }),
457
+ ...(drift === undefined
458
+ ? {}
459
+ : {
460
+ committedHash: drift.committedHash,
461
+ currentHash: drift.currentHash,
462
+ }),
463
+ freshness,
464
+ modulePath: app.modulePath,
465
+ moduleSource: app.moduleSource,
466
+ provenance: 'warden-app-drift' as const,
467
+ status,
468
+ };
469
+ });
470
+ return {
471
+ apps: apps.toSorted((left, right) => left.appId.localeCompare(right.appId)),
472
+ completeness: apps.every((app) => app.status === 'available')
473
+ ? 'complete'
474
+ : 'partial',
475
+ };
476
+ };
477
+
478
+ export const wardenTrail = trail('warden', {
479
+ description: 'Run governance checks (lint + drift)',
480
+ examples: [
481
+ {
482
+ input: markCurrentAppExampleInput(
483
+ {
484
+ depth: 'source',
485
+ lock: 'skip',
486
+ rootDir: '.',
487
+ },
488
+ { selection: 'configured-project' }
489
+ ),
490
+ name: 'Default warden run',
491
+ },
492
+ {
493
+ input: markCurrentAppExampleInput(
494
+ {
495
+ depth: 'source',
496
+ format: 'github',
497
+ lock: 'skip',
498
+ rootDir: '.',
499
+ },
500
+ { selection: 'configured-project' }
501
+ ),
502
+ name: 'GitHub Actions annotations',
503
+ },
504
+ ],
505
+ implementation: async (input, ctx) => {
506
+ const contextResult = await resolveOperatorProjectContext(input, {
507
+ cwd: ctx.cwd,
508
+ });
509
+ if (contextResult.isErr()) {
510
+ return contextResult;
511
+ }
512
+ const context = contextResult.value;
513
+ const apps = selectedWardenApps(context, input.apps);
514
+ if (apps.isErr()) {
515
+ return apps;
516
+ }
517
+ if (fixRunNeedsArtifactPreflight(input)) {
518
+ const fixPreflight = await assertWardenArtifactPreflight(context);
519
+ if (fixPreflight.isErr()) {
520
+ return fixPreflight;
521
+ }
522
+ }
523
+ const result = await runWardenCommand({
524
+ args: buildWardenCommandArgs({
525
+ ...input,
526
+ apps: apps.value.apps === undefined ? undefined : [...apps.value.apps],
527
+ }),
528
+ cwd: context.projectRoot,
529
+ env: ctx.env ?? {},
530
+ expectedAppBindings: apps.value.expectedAppBindings,
531
+ });
532
+ if (result.preflightError !== undefined) {
533
+ return Result.err(result.preflightError);
534
+ }
535
+ const { report } = result;
536
+ if (consumesSavedArtifactEvidence(report.effectiveConfig)) {
537
+ const preflight = await assertWardenArtifactPreflight(context);
538
+ if (preflight.isErr()) {
539
+ return preflight;
540
+ }
541
+ }
542
+ const project = operatorProjectContextOutput(context);
543
+ const workspaceEvidence = deriveWardenWorkspaceEvidence(
544
+ context,
545
+ report.topoDrift
546
+ );
547
+ const formatted = renderWardenSelection(
548
+ result.output,
549
+ report.effectiveConfig?.format ?? 'summary',
550
+ project,
551
+ workspaceEvidence
552
+ );
553
+ if (formatted.isErr()) {
554
+ return formatted;
555
+ }
556
+
557
+ return Result.ok({
558
+ diagnostics: [...report.diagnostics],
559
+ drift: report.drift,
560
+ errorCount: report.errorCount,
561
+ fixes: report.fixes,
562
+ formatted: formatted.value,
563
+ passed: report.passed,
564
+ project,
565
+ ...(workspaceEvidence === undefined ? {} : { workspaceEvidence }),
566
+ warnCount: report.warnCount,
567
+ });
568
+ },
569
+ input: wardenInputSchema,
570
+ intent: 'write',
571
+ output: z.object({
572
+ diagnostics: z.array(
573
+ diagnosticSchema.extend({ topoName: z.string().optional() })
574
+ ),
575
+ drift: z
576
+ .object({
577
+ blockedReason: z.string().optional(),
578
+ committedHash: z.string().nullable(),
579
+ currentHash: z.string(),
580
+ stale: z.boolean(),
581
+ })
582
+ .nullable(),
583
+ errorCount: z.number(),
584
+ fixes: z
585
+ .object({
586
+ applied: z.number(),
587
+ filesChanged: z.number(),
588
+ skipped: z.number(),
589
+ })
590
+ .optional(),
591
+ formatted: z.string(),
592
+ passed: z.boolean(),
593
+ project: operatorProjectContextOutputSchema,
594
+ warnCount: z.number(),
595
+ workspaceEvidence: wardenWorkspaceEvidenceSchema.optional(),
596
+ }),
597
+ permit: 'public',
598
+ });