@ontrails/warden 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 (188) hide show
  1. package/CHANGELOG.md +1556 -0
  2. package/README.md +200 -0
  3. package/bin/warden.ts +51 -0
  4. package/package.json +51 -0
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +2159 -0
  7. package/src/command.ts +1167 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +260 -0
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +178 -0
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +295 -0
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/regrade-history.ts +188 -0
  18. package/src/regrade-receipt.ts +484 -0
  19. package/src/resolve.ts +531 -0
  20. package/src/rules/activation-orphan.ts +97 -0
  21. package/src/rules/captured-kernel.ts +375 -0
  22. package/src/rules/circular-refs.ts +150 -0
  23. package/src/rules/cli-command-route-coherence.ts +177 -0
  24. package/src/rules/composes-declarations.ts +839 -0
  25. package/src/rules/context-no-surface-types.ts +214 -0
  26. package/src/rules/dead-internal-trail.ts +161 -0
  27. package/src/rules/dead-public-trail.ts +258 -0
  28. package/src/rules/draft-file-marking.ts +155 -0
  29. package/src/rules/draft-visible-debt.ts +83 -0
  30. package/src/rules/duplicate-exported-symbol.ts +172 -0
  31. package/src/rules/duplicate-public-contract.ts +137 -0
  32. package/src/rules/entity-exists.ts +254 -0
  33. package/src/rules/entity-ids.ts +15 -0
  34. package/src/rules/error-mapping-completeness.ts +290 -0
  35. package/src/rules/example-valid.ts +395 -0
  36. package/src/rules/fires-declarations.ts +740 -0
  37. package/src/rules/governed-symbol-residue.ts +535 -0
  38. package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
  39. package/src/rules/implementation-returns-result.ts +1733 -0
  40. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  41. package/src/rules/incomplete-crud.ts +583 -0
  42. package/src/rules/index.ts +328 -0
  43. package/src/rules/intent-propagation.ts +125 -0
  44. package/src/rules/layer-field-name-drift.ts +102 -0
  45. package/src/rules/library-render-coherence.ts +97 -0
  46. package/src/rules/metadata.ts +929 -0
  47. package/src/rules/missing-reconcile.ts +97 -0
  48. package/src/rules/missing-visibility.ts +111 -0
  49. package/src/rules/no-destructured-compose.ts +196 -0
  50. package/src/rules/no-dev-permit-in-source.ts +99 -0
  51. package/src/rules/no-direct-implementation-call.ts +52 -0
  52. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  53. package/src/rules/no-legacy-layer-imports.ts +211 -0
  54. package/src/rules/no-native-error-result.ts +118 -0
  55. package/src/rules/no-redundant-result-error-wrap.ts +382 -0
  56. package/src/rules/no-retired-cross-vocabulary.ts +203 -0
  57. package/src/rules/no-sync-result-assumption.ts +1199 -0
  58. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  59. package/src/rules/no-throw-in-implementation.ts +48 -0
  60. package/src/rules/no-top-level-surface.ts +371 -0
  61. package/src/rules/on-references-exist.ts +194 -0
  62. package/src/rules/orphaned-signal.ts +149 -0
  63. package/src/rules/owner-render-parity.ts +143 -0
  64. package/src/rules/permit-governance.ts +25 -0
  65. package/src/rules/prefer-schema-inference.ts +141 -0
  66. package/src/rules/public-export-example-coverage.ts +561 -0
  67. package/src/rules/public-internal-deep-imports.ts +454 -0
  68. package/src/rules/public-output-schema.ts +29 -0
  69. package/src/rules/public-union-output-discriminants.ts +150 -0
  70. package/src/rules/read-intent-fires.ts +188 -0
  71. package/src/rules/reference-exists.ts +97 -0
  72. package/src/rules/registry-names.ts +171 -0
  73. package/src/rules/resolved-import-boundary.ts +146 -0
  74. package/src/rules/resource-declarations.ts +697 -0
  75. package/src/rules/resource-exists.ts +181 -0
  76. package/src/rules/resource-id-grammar.ts +65 -0
  77. package/src/rules/resource-mock-coverage.ts +115 -0
  78. package/src/rules/retired-vocabulary.ts +1407 -0
  79. package/src/rules/scan.ts +59 -0
  80. package/src/rules/scheduled-destroy-intent.ts +44 -0
  81. package/src/rules/signal-graph-coaching.ts +220 -0
  82. package/src/rules/source/composition.ts +165 -0
  83. package/src/rules/source/drafts.ts +164 -0
  84. package/src/rules/source/entities.ts +618 -0
  85. package/src/rules/source/pragmas.ts +45 -0
  86. package/src/rules/source/resources.ts +64 -0
  87. package/src/rules/source/signals.ts +397 -0
  88. package/src/rules/source/stores.ts +310 -0
  89. package/src/rules/specs.ts +388 -0
  90. package/src/rules/static-resource-accessor-preference.ts +654 -0
  91. package/src/rules/structure.ts +234 -0
  92. package/src/rules/surface-overlay-coherence.ts +262 -0
  93. package/src/rules/surface-trailhead-coherence.ts +366 -0
  94. package/src/rules/trail-fork-coaching.ts +625 -0
  95. package/src/rules/trail-versioning-source.ts +1076 -0
  96. package/src/rules/trail-versioning-topo.ts +172 -0
  97. package/src/rules/trailhead-override-divergence.ts +356 -0
  98. package/src/rules/types.ts +464 -0
  99. package/src/rules/unmaterialized-activation-source.ts +85 -0
  100. package/src/rules/unreachable-detour-shadowing.ts +343 -0
  101. package/src/rules/valid-describe-refs.ts +224 -0
  102. package/src/rules/valid-detour-contract.ts +78 -0
  103. package/src/rules/warden-export-symmetry.ts +540 -0
  104. package/src/rules/warden-rules-use-ast.ts +1109 -0
  105. package/src/rules/webhook-route-collision.ts +306 -0
  106. package/src/rules/workspace-lock-ownership.ts +34 -0
  107. package/src/trails/activation-orphan.trail.ts +84 -0
  108. package/src/trails/captured-kernel.trail.ts +108 -0
  109. package/src/trails/circular-refs.trail.ts +29 -0
  110. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  111. package/src/trails/composes-declarations.trail.ts +22 -0
  112. package/src/trails/context-no-surface-types.trail.ts +21 -0
  113. package/src/trails/dead-internal-trail.trail.ts +26 -0
  114. package/src/trails/dead-public-trail.trail.ts +31 -0
  115. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  116. package/src/trails/draft-file-marking.trail.ts +16 -0
  117. package/src/trails/draft-visible-debt.trail.ts +16 -0
  118. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  119. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  120. package/src/trails/entity-exists.trail.ts +21 -0
  121. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  122. package/src/trails/example-valid.trail.ts +25 -0
  123. package/src/trails/fires-declarations.trail.ts +23 -0
  124. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  125. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  126. package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
  127. package/src/trails/implementation-returns-result.trail.ts +20 -0
  128. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  129. package/src/trails/incomplete-crud.trail.ts +39 -0
  130. package/src/trails/index.ts +91 -0
  131. package/src/trails/intent-propagation.trail.ts +30 -0
  132. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  133. package/src/trails/library-render-coherence.trail.ts +43 -0
  134. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  135. package/src/trails/missing-reconcile.trail.ts +33 -0
  136. package/src/trails/missing-visibility.trail.ts +22 -0
  137. package/src/trails/no-destructured-compose.trail.ts +44 -0
  138. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  139. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  140. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  141. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  142. package/src/trails/no-native-error-result.trail.ts +18 -0
  143. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  144. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  145. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  146. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  147. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  148. package/src/trails/no-top-level-surface.trail.ts +43 -0
  149. package/src/trails/on-references-exist.trail.ts +21 -0
  150. package/src/trails/orphaned-signal.trail.ts +36 -0
  151. package/src/trails/owner-render-parity.trail.ts +26 -0
  152. package/src/trails/pending-force.trail.ts +21 -0
  153. package/src/trails/permit-governance.trail.ts +51 -0
  154. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  155. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  156. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  157. package/src/trails/public-output-schema.trail.ts +55 -0
  158. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  159. package/src/trails/read-intent-fires.trail.ts +20 -0
  160. package/src/trails/reference-exists.trail.ts +25 -0
  161. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  162. package/src/trails/resource-declarations.trail.ts +25 -0
  163. package/src/trails/resource-exists.trail.ts +27 -0
  164. package/src/trails/resource-id-grammar.trail.ts +39 -0
  165. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  166. package/src/trails/run.ts +205 -0
  167. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  168. package/src/trails/schema.ts +295 -0
  169. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  170. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  171. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  172. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  173. package/src/trails/topo.ts +6 -0
  174. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  175. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  176. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  177. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  178. package/src/trails/valid-describe-refs.trail.ts +18 -0
  179. package/src/trails/valid-detour-contract.trail.ts +71 -0
  180. package/src/trails/version-gap.trail.ts +35 -0
  181. package/src/trails/version-pinned-compose.trail.ts +23 -0
  182. package/src/trails/version-without-examples.trail.ts +38 -0
  183. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  184. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  185. package/src/trails/webhook-route-collision.trail.ts +50 -0
  186. package/src/trails/workspace-lock-ownership.trail.ts +16 -0
  187. package/src/trails/wrap-rule.ts +269 -0
  188. package/src/workspaces.ts +264 -0
package/src/command.ts ADDED
@@ -0,0 +1,1167 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { parseArgs } from 'node:util';
5
+
6
+ import {
7
+ deriveCliFlagValueAliases,
8
+ findAppModule,
9
+ findAppModuleCandidates,
10
+ } from '@ontrails/cli';
11
+ import type {
12
+ CliFlagValueAlias,
13
+ CliFlagValueAliasDeclaration,
14
+ } from '@ontrails/cli';
15
+ import { resolveTrailsOverlays } from '@ontrails/adapter-kit';
16
+ import type { Topo } from '@ontrails/core';
17
+ import { AmbiguousError, NotFoundError, ValidationError } from '@ontrails/core';
18
+ import type { TopoGraphOverlayRegistration } from '@ontrails/topography';
19
+ import {
20
+ loadTrailsConfigValue,
21
+ readTrailsProjectIdentity,
22
+ resolveTrailsProjectRoot,
23
+ } from '@ontrails/config';
24
+
25
+ import type {
26
+ WardenConfigInput,
27
+ WardenConfigLayer,
28
+ WardenDepth,
29
+ WardenDraftsMode,
30
+ WardenFailOn,
31
+ WardenFormat,
32
+ WardenScope,
33
+ WardenLockMode,
34
+ } from './config.js';
35
+ import {
36
+ resolveWardenConfig,
37
+ wardenDepthValues,
38
+ wardenDraftsValues,
39
+ wardenFailOnValues,
40
+ wardenFormatValues,
41
+ wardenLockValues,
42
+ } from './config.js';
43
+ import type {
44
+ WardenReport,
45
+ WardenRunOptions,
46
+ WardenTopoTarget,
47
+ } from './cli.js';
48
+ import { runWarden } from './cli.js';
49
+ import {
50
+ formatGitHubAnnotations,
51
+ formatJson,
52
+ formatSummary,
53
+ } from './formatters.js';
54
+ import type { WardenDiagnostic, WardenSeverity } from './rules/types.js';
55
+
56
+ type EnvRecord = Record<string, string | undefined>;
57
+
58
+ interface MutableWardenConfigLayer {
59
+ apps?: string[] | undefined;
60
+ depth?: WardenDepth | undefined;
61
+ drafts?: WardenDraftsMode | undefined;
62
+ failOn?: WardenFailOn | undefined;
63
+ format?: WardenFormat | undefined;
64
+ scope?: WardenScope | undefined;
65
+ lock?: WardenLockMode | undefined;
66
+ noLockMutation?: boolean | undefined;
67
+ }
68
+
69
+ const diagnostic = ({
70
+ filePath = '<warden-cli>',
71
+ message,
72
+ rule = 'warden-cli',
73
+ severity = 'error',
74
+ }: {
75
+ readonly filePath?: string | undefined;
76
+ readonly message: string;
77
+ readonly rule?: string | undefined;
78
+ readonly severity?: WardenSeverity | undefined;
79
+ }): WardenDiagnostic => ({
80
+ filePath,
81
+ line: 1,
82
+ message,
83
+ rule,
84
+ severity,
85
+ });
86
+
87
+ const errorMessage = (error: unknown): string =>
88
+ error instanceof Error ? error.message : String(error);
89
+
90
+ const cleanUndefined = <T extends Record<string, unknown>>(
91
+ value: T
92
+ ): Partial<T> =>
93
+ Object.fromEntries(
94
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
95
+ ) as Partial<T>;
96
+
97
+ const splitApps = (value: string): readonly string[] =>
98
+ value
99
+ .split(',')
100
+ .map((entry) => entry.trim())
101
+ .filter((entry) => entry.length > 0);
102
+
103
+ const splitCommaDelimitedValues = (value: string): readonly string[] =>
104
+ value
105
+ .split(',')
106
+ .map((entry) => entry.trim())
107
+ .filter((entry) => entry.length > 0);
108
+
109
+ const isAllowedValue = <T extends string>(
110
+ value: string,
111
+ allowed: readonly T[]
112
+ ): value is T => allowed.includes(value as T);
113
+
114
+ interface EnumReadOptions<T extends string> {
115
+ readonly allowed: readonly T[];
116
+ readonly diagnostics: WardenDiagnostic[];
117
+ readonly flag: string;
118
+ readonly value: string | undefined;
119
+ }
120
+
121
+ const readEnumValue = <T extends string>({
122
+ allowed,
123
+ diagnostics,
124
+ flag,
125
+ value,
126
+ }: EnumReadOptions<T>): T | undefined => {
127
+ if (value !== undefined && isAllowedValue(value, allowed)) {
128
+ return value;
129
+ }
130
+ diagnostics.push(
131
+ diagnostic({
132
+ message: `Invalid ${flag} value "${value ?? ''}". Expected one of: ${allowed.join(', ')}.`,
133
+ })
134
+ );
135
+ return undefined;
136
+ };
137
+
138
+ export interface ParsedWardenCommand {
139
+ readonly adapterCheck: boolean;
140
+ readonly ci: boolean;
141
+ readonly cli: WardenConfigLayer;
142
+ readonly configPath?: string | undefined;
143
+ readonly diagnostics: readonly WardenDiagnostic[];
144
+ readonly fix: boolean;
145
+ readonly prePush: boolean;
146
+ readonly rootDir?: string | undefined;
147
+ }
148
+
149
+ const createEmptyParsedCommand = (message: string): ParsedWardenCommand => ({
150
+ adapterCheck: false,
151
+ ci: false,
152
+ cli: {},
153
+ diagnostics: [diagnostic({ message })],
154
+ fix: false,
155
+ prePush: false,
156
+ });
157
+
158
+ const tokenValue = (token: {
159
+ readonly value?: string | boolean | undefined;
160
+ }): string | undefined =>
161
+ typeof token.value === 'string' ? token.value : undefined;
162
+
163
+ interface CommandParserState {
164
+ adapterCheck?: boolean | undefined;
165
+ readonly apps: string[];
166
+ readonly diagnostics: WardenDiagnostic[];
167
+ readonly cli: MutableWardenConfigLayer;
168
+ configPath?: string | undefined;
169
+ fix?: boolean | undefined;
170
+ rootDir?: string | undefined;
171
+ }
172
+
173
+ type AliasConfigKey = 'drafts' | 'format' | 'lock';
174
+
175
+ interface WardenAliasSpec {
176
+ readonly aliases: CliFlagValueAliasDeclaration;
177
+ readonly choices: readonly string[];
178
+ readonly configKey: AliasConfigKey;
179
+ readonly flagName: string;
180
+ }
181
+
182
+ interface WardenValueAliasTarget {
183
+ readonly alias: CliFlagValueAlias;
184
+ readonly configKey: AliasConfigKey;
185
+ }
186
+
187
+ const wardenAliasSpecs: readonly WardenAliasSpec[] = [
188
+ {
189
+ aliases: true,
190
+ choices: wardenFormatValues,
191
+ configKey: 'format',
192
+ flagName: 'format',
193
+ },
194
+ {
195
+ aliases: {
196
+ cached: 'cached',
197
+ refresh: 'refresh',
198
+ skip: 'skip-lock',
199
+ },
200
+ choices: wardenLockValues,
201
+ configKey: 'lock',
202
+ flagName: 'lock',
203
+ },
204
+ {
205
+ aliases: {
206
+ exclude: 'exclude-drafts',
207
+ include: 'include-drafts',
208
+ only: 'only-drafts',
209
+ },
210
+ choices: wardenDraftsValues,
211
+ configKey: 'drafts',
212
+ flagName: 'drafts',
213
+ },
214
+ ];
215
+
216
+ const wardenValueAliasTargets: readonly WardenValueAliasTarget[] =
217
+ wardenAliasSpecs.flatMap((spec) =>
218
+ (
219
+ deriveCliFlagValueAliases({
220
+ aliases: spec.aliases,
221
+ choices: spec.choices,
222
+ flagName: spec.flagName,
223
+ }) ?? []
224
+ ).map((alias) => ({
225
+ alias,
226
+ configKey: spec.configKey,
227
+ }))
228
+ );
229
+
230
+ const wardenValueAliasTargetByName = new Map(
231
+ wardenValueAliasTargets.map((target) => [target.alias.name, target])
232
+ );
233
+
234
+ const valueAliasParseOptions = Object.fromEntries(
235
+ wardenValueAliasTargets.map((target) => [
236
+ target.alias.name,
237
+ { type: 'boolean' as const },
238
+ ])
239
+ );
240
+
241
+ const parseTokens = (
242
+ args: readonly string[]
243
+ ): ReturnType<typeof parseArgs> | { readonly error: string } => {
244
+ try {
245
+ return parseArgs({
246
+ allowPositionals: false,
247
+ args: [...args],
248
+ options: {
249
+ 'adapter-check': { type: 'boolean' },
250
+ apps: { multiple: true, short: 'a', type: 'string' },
251
+ ci: { type: 'boolean' },
252
+ 'config-path': { type: 'string' },
253
+ depth: { type: 'string' },
254
+ drafts: { type: 'string' },
255
+ 'fail-on': { type: 'string' },
256
+ fix: { type: 'boolean' },
257
+ format: { type: 'string' },
258
+ lock: { type: 'string' },
259
+ 'no-lock-mutation': { type: 'boolean' },
260
+ 'pre-push': { type: 'boolean' },
261
+ 'root-dir': { type: 'string' },
262
+ 'scope-exclude': { multiple: true, type: 'string' },
263
+ strict: { type: 'boolean' },
264
+ ...valueAliasParseOptions,
265
+ },
266
+ strict: true,
267
+ tokens: true,
268
+ });
269
+ } catch (error) {
270
+ return { error: errorMessage(error) };
271
+ }
272
+ };
273
+
274
+ const isParseError = (
275
+ value: ReturnType<typeof parseArgs> | { readonly error: string }
276
+ ): value is { readonly error: string } => 'error' in value;
277
+
278
+ const applyPresetToken = (
279
+ token: NonNullable<ReturnType<typeof parseArgs>['tokens']>[number],
280
+ cli: MutableWardenConfigLayer
281
+ ): { readonly ci: boolean; readonly prePush: boolean } => {
282
+ if (token.kind !== 'option') {
283
+ return { ci: false, prePush: false };
284
+ }
285
+ if (token.name === 'pre-push') {
286
+ Object.assign(cli, {
287
+ depth: 'project',
288
+ failOn: 'error',
289
+ lock: 'cached',
290
+ } satisfies WardenConfigLayer);
291
+ return { ci: false, prePush: true };
292
+ }
293
+ if (token.name === 'ci') {
294
+ Object.assign(cli, {
295
+ depth: 'all',
296
+ failOn: 'error',
297
+ format: 'github',
298
+ lock: 'auto',
299
+ noLockMutation: true,
300
+ } satisfies WardenConfigLayer);
301
+ return { ci: true, prePush: false };
302
+ }
303
+ return { ci: false, prePush: false };
304
+ };
305
+
306
+ const applyAliasOption = (name: string, state: CommandParserState): boolean => {
307
+ const target = wardenValueAliasTargetByName.get(name);
308
+ if (target === undefined) {
309
+ return false;
310
+ }
311
+ if (target.configKey === 'format') {
312
+ state.cli.format = target.alias.value as WardenFormat;
313
+ } else if (target.configKey === 'lock') {
314
+ state.cli.lock = target.alias.value as WardenLockMode;
315
+ } else {
316
+ state.cli.drafts = target.alias.value as WardenDraftsMode;
317
+ }
318
+ return true;
319
+ };
320
+
321
+ const applyEnumOption = (
322
+ name: string,
323
+ value: string | undefined,
324
+ state: CommandParserState
325
+ ): boolean => {
326
+ if (name === 'depth') {
327
+ state.cli.depth = readEnumValue<WardenDepth>({
328
+ allowed: wardenDepthValues,
329
+ diagnostics: state.diagnostics,
330
+ flag: '--depth',
331
+ value,
332
+ });
333
+ return true;
334
+ }
335
+ if (name === 'drafts') {
336
+ state.cli.drafts = readEnumValue<WardenDraftsMode>({
337
+ allowed: wardenDraftsValues,
338
+ diagnostics: state.diagnostics,
339
+ flag: '--drafts',
340
+ value,
341
+ });
342
+ return true;
343
+ }
344
+ if (name === 'fail-on') {
345
+ state.cli.failOn = readEnumValue<WardenFailOn>({
346
+ allowed: wardenFailOnValues,
347
+ diagnostics: state.diagnostics,
348
+ flag: '--fail-on',
349
+ value,
350
+ });
351
+ return true;
352
+ }
353
+ if (name === 'format') {
354
+ state.cli.format = readEnumValue<WardenFormat>({
355
+ allowed: wardenFormatValues,
356
+ diagnostics: state.diagnostics,
357
+ flag: '--format',
358
+ value,
359
+ });
360
+ return true;
361
+ }
362
+ if (name === 'lock') {
363
+ state.cli.lock = readEnumValue<WardenLockMode>({
364
+ allowed: wardenLockValues,
365
+ diagnostics: state.diagnostics,
366
+ flag: '--lock',
367
+ value,
368
+ });
369
+ return true;
370
+ }
371
+ return false;
372
+ };
373
+
374
+ const applyCommandOption = (
375
+ token: NonNullable<ReturnType<typeof parseArgs>['tokens']>[number],
376
+ state: CommandParserState
377
+ ): void => {
378
+ if (
379
+ token.kind !== 'option' ||
380
+ token.name === 'ci' ||
381
+ token.name === 'pre-push'
382
+ ) {
383
+ return;
384
+ }
385
+
386
+ const value = tokenValue(token);
387
+ if (
388
+ applyAliasOption(token.name, state) ||
389
+ applyEnumOption(token.name, value, state)
390
+ ) {
391
+ return;
392
+ }
393
+
394
+ if (token.name === 'apps') {
395
+ if (value === undefined) {
396
+ state.diagnostics.push(
397
+ diagnostic({ message: '--apps requires a comma-delimited value.' })
398
+ );
399
+ return;
400
+ }
401
+ state.apps.push(...splitApps(value));
402
+ return;
403
+ }
404
+ if (token.name === 'adapter-check') {
405
+ state.adapterCheck = true;
406
+ return;
407
+ }
408
+ if (token.name === 'config-path') {
409
+ state.configPath = value;
410
+ return;
411
+ }
412
+ if (token.name === 'scope-exclude') {
413
+ if (value === undefined) {
414
+ state.diagnostics.push(
415
+ diagnostic({ message: '--scope-exclude requires a path glob.' })
416
+ );
417
+ return;
418
+ }
419
+ const existing = state.cli.scope?.exclude ?? [];
420
+ state.cli.scope = {
421
+ exclude: [...existing, ...splitCommaDelimitedValues(value)],
422
+ };
423
+ return;
424
+ }
425
+ if (token.name === 'fix') {
426
+ state.fix = true;
427
+ return;
428
+ }
429
+ if (token.name === 'no-lock-mutation') {
430
+ state.cli.noLockMutation = true;
431
+ return;
432
+ }
433
+ if (token.name === 'root-dir') {
434
+ state.rootDir = value;
435
+ return;
436
+ }
437
+ if (token.name === 'strict') {
438
+ state.cli.failOn = 'warning';
439
+ return;
440
+ }
441
+
442
+ state.diagnostics.push(
443
+ diagnostic({ message: `Unsupported Warden option: --${token.name}` })
444
+ );
445
+ };
446
+
447
+ export const parseWardenCommandArgs = (
448
+ args: readonly string[]
449
+ ): ParsedWardenCommand => {
450
+ const parsed = parseTokens(args);
451
+ if (isParseError(parsed)) {
452
+ return createEmptyParsedCommand(parsed.error);
453
+ }
454
+
455
+ const state: CommandParserState = {
456
+ apps: [],
457
+ cli: {},
458
+ diagnostics: [],
459
+ };
460
+ let ci = false;
461
+ let prePush = false;
462
+
463
+ for (const token of parsed.tokens ?? []) {
464
+ const preset = applyPresetToken(token, state.cli);
465
+ ci = ci || preset.ci;
466
+ prePush = prePush || preset.prePush;
467
+ }
468
+
469
+ for (const token of parsed.tokens ?? []) {
470
+ applyCommandOption(token, state);
471
+ }
472
+
473
+ if (state.apps.length > 0) {
474
+ state.cli.apps = state.apps;
475
+ }
476
+
477
+ return {
478
+ adapterCheck: state.adapterCheck ?? false,
479
+ ci,
480
+ cli: cleanUndefined(
481
+ state.cli as Record<string, unknown>
482
+ ) as WardenConfigLayer,
483
+ configPath: state.configPath,
484
+ diagnostics: state.diagnostics,
485
+ fix: state.fix ?? false,
486
+ prePush,
487
+ rootDir: state.rootDir,
488
+ };
489
+ };
490
+
491
+ interface WardenConfigLoadResult {
492
+ readonly config?: WardenConfigInput | undefined;
493
+ readonly configPath?: string | undefined;
494
+ readonly diagnostics: readonly WardenDiagnostic[];
495
+ }
496
+
497
+ const findConfigPath = (
498
+ rootDir: string,
499
+ configPath: string | undefined
500
+ ): WardenConfigLoadResult => {
501
+ if (configPath !== undefined) {
502
+ const resolvedPath = resolve(rootDir, configPath);
503
+ return existsSync(resolvedPath)
504
+ ? { configPath: resolvedPath, diagnostics: [] }
505
+ : {
506
+ diagnostics: [
507
+ diagnostic({
508
+ filePath: resolvedPath,
509
+ message: `Warden config file not found: ${resolvedPath}`,
510
+ rule: 'warden-config',
511
+ }),
512
+ ],
513
+ };
514
+ }
515
+
516
+ return { diagnostics: [] };
517
+ };
518
+
519
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
520
+ typeof value === 'object' && value !== null;
521
+
522
+ interface ResultLike {
523
+ readonly error?: unknown;
524
+ readonly value?: unknown;
525
+ isErr(): boolean;
526
+ isOk(): boolean;
527
+ }
528
+
529
+ const isResultLike = (value: unknown): value is ResultLike =>
530
+ isRecord(value) &&
531
+ typeof value['isOk'] === 'function' &&
532
+ typeof value['isErr'] === 'function';
533
+
534
+ interface ResolvableConfig {
535
+ resolve(options: {
536
+ readonly cwd: string;
537
+ readonly env: EnvRecord;
538
+ }): Promise<unknown>;
539
+ }
540
+
541
+ const isResolvableConfig = (value: unknown): value is ResolvableConfig =>
542
+ isRecord(value) && typeof value['resolve'] === 'function';
543
+
544
+ const extractWardenConfig = (value: unknown): WardenConfigInput | undefined =>
545
+ isRecord(value) && 'warden' in value
546
+ ? (value['warden'] as WardenConfigInput)
547
+ : undefined;
548
+
549
+ export const loadWardenConfig = async ({
550
+ configPath,
551
+ env = {},
552
+ rootDir,
553
+ }: {
554
+ readonly configPath?: string | undefined;
555
+ readonly env?: EnvRecord | undefined;
556
+ readonly rootDir: string;
557
+ }): Promise<WardenConfigLoadResult> => {
558
+ const located = findConfigPath(rootDir, configPath);
559
+ if (configPath !== undefined && located.configPath === undefined) {
560
+ return located;
561
+ }
562
+
563
+ try {
564
+ const loaded = await loadTrailsConfigValue({ configPath, rootDir });
565
+ const exported = loaded.value;
566
+ if (exported === undefined) {
567
+ return located;
568
+ }
569
+ if (isResolvableConfig(exported)) {
570
+ const resolved = await exported.resolve({ cwd: rootDir, env });
571
+ if (isResultLike(resolved)) {
572
+ if (resolved.isOk()) {
573
+ return {
574
+ config: extractWardenConfig(resolved.value),
575
+ configPath: loaded.configPath,
576
+ diagnostics: located.diagnostics,
577
+ };
578
+ }
579
+ return {
580
+ configPath: loaded.configPath,
581
+ diagnostics: [
582
+ ...located.diagnostics,
583
+ diagnostic({
584
+ filePath: loaded.configPath ?? rootDir,
585
+ message: `Failed to resolve Warden config: ${errorMessage(resolved.error)}`,
586
+ rule: 'warden-config',
587
+ }),
588
+ ],
589
+ };
590
+ }
591
+ return {
592
+ config: extractWardenConfig(resolved),
593
+ configPath: loaded.configPath,
594
+ diagnostics: located.diagnostics,
595
+ };
596
+ }
597
+ return {
598
+ config: extractWardenConfig(exported),
599
+ configPath: loaded.configPath,
600
+ diagnostics: located.diagnostics,
601
+ };
602
+ } catch (error) {
603
+ return {
604
+ configPath: located.configPath ?? configPath,
605
+ diagnostics: [
606
+ ...located.diagnostics,
607
+ diagnostic({
608
+ filePath: located.configPath ?? rootDir,
609
+ message: `Failed to load Warden config: ${errorMessage(error)}`,
610
+ rule: 'warden-config',
611
+ }),
612
+ ],
613
+ };
614
+ }
615
+ };
616
+
617
+ const isTopo = (value: unknown): value is Topo => {
618
+ if (!isRecord(value)) {
619
+ return false;
620
+ }
621
+ return (
622
+ value['trails'] instanceof Map &&
623
+ value['signals'] instanceof Map &&
624
+ value['resources'] instanceof Map &&
625
+ value['entities'] instanceof Map &&
626
+ typeof value['get'] === 'function' &&
627
+ typeof value['list'] === 'function' &&
628
+ typeof value['name'] === 'string'
629
+ );
630
+ };
631
+
632
+ const TOPO_EXPORT_KEYS = ['default', 'graph', 'app'] as const;
633
+
634
+ const extractTopo = (
635
+ modulePath: string,
636
+ loaded: Record<string, unknown>
637
+ ): Topo => {
638
+ for (const key of TOPO_EXPORT_KEYS) {
639
+ const candidate = loaded[key];
640
+ if (isTopo(candidate)) {
641
+ return candidate;
642
+ }
643
+ }
644
+
645
+ throw new Error(
646
+ `Could not find a Topo export in "${modulePath}". Expected a default, "graph", or "app" export created with topo().`
647
+ );
648
+ };
649
+
650
+ const resolveFilesystemModulePath = (
651
+ rootDir: string,
652
+ modulePath: string
653
+ ): string => {
654
+ const absolutePath = isAbsolute(modulePath)
655
+ ? modulePath
656
+ : resolve(rootDir, modulePath);
657
+ if (!absolutePath.endsWith('.js') || existsSync(absolutePath)) {
658
+ return absolutePath;
659
+ }
660
+
661
+ const tsPath = absolutePath.replace(/\.js$/, '.ts');
662
+ return existsSync(tsPath) ? tsPath : absolutePath;
663
+ };
664
+
665
+ const resolveDiscoveredModulePath = (
666
+ rootDir: string,
667
+ explicit?: string | undefined
668
+ ): string =>
669
+ resolveFilesystemModulePath(rootDir, findAppModule(rootDir, explicit));
670
+
671
+ const appCandidateMatches = (candidate: string, appName: string): boolean =>
672
+ candidate === appName ||
673
+ candidate === `apps/${appName}/src/app.ts` ||
674
+ candidate.startsWith(`apps/${appName}/`);
675
+
676
+ const resolveNamedAppModulePath = (
677
+ rootDir: string,
678
+ appName: string
679
+ ): string => {
680
+ const matched = findAppModuleCandidates(rootDir).find((candidate) =>
681
+ appCandidateMatches(candidate, appName)
682
+ );
683
+ return matched === undefined
684
+ ? resolveDiscoveredModulePath(rootDir, appName)
685
+ : resolveFilesystemModulePath(rootDir, matched);
686
+ };
687
+
688
+ interface LoadedWardenTopoModule {
689
+ /** App-module overlay registrations read through the shared channel. */
690
+ readonly overlays?: readonly TopoGraphOverlayRegistration[] | undefined;
691
+ readonly topo: Topo;
692
+ }
693
+
694
+ const importTopoFromModulePath = async (
695
+ modulePath: string
696
+ ): Promise<LoadedWardenTopoModule> => {
697
+ const loaded = (await import(pathToFileURL(modulePath).href)) as Record<
698
+ string,
699
+ unknown
700
+ >;
701
+ const topo = extractTopo(modulePath, loaded);
702
+ const overlays = resolveTrailsOverlays(loaded, modulePath);
703
+ return overlays === undefined ? { topo } : { overlays, topo };
704
+ };
705
+
706
+ const topoLoadDiagnostic = ({
707
+ filePath,
708
+ message,
709
+ severity,
710
+ }: {
711
+ readonly filePath: string;
712
+ readonly message: string;
713
+ readonly severity: WardenSeverity;
714
+ }): WardenDiagnostic =>
715
+ diagnostic({
716
+ filePath,
717
+ message,
718
+ rule: 'topo-load',
719
+ severity,
720
+ });
721
+
722
+ const WARDEN_TOPO_SELECTION_HINT =
723
+ 'Declare workspace.apps in Trails Config or pass --apps NAME,NAME.';
724
+
725
+ const cleanDiscoveryMessage = (message: string): string =>
726
+ message
727
+ .replaceAll('\n\nUse --module to select one explicitly.', '')
728
+ .replaceAll(' Use --module to specify the path.', '')
729
+ .trim();
730
+
731
+ const ambiguousTopoDiagnostic = (
732
+ rootDir: string,
733
+ message: string,
734
+ strict: boolean
735
+ ): WardenDiagnostic =>
736
+ topoLoadDiagnostic({
737
+ filePath: rootDir,
738
+ message: `Multiple Trails apps discovered; skipping topo-aware rules. ${WARDEN_TOPO_SELECTION_HINT} ${cleanDiscoveryMessage(message)}`,
739
+ severity: strict ? 'error' : 'warn',
740
+ });
741
+
742
+ const missingTopoDiagnostic = (
743
+ rootDir: string,
744
+ message: string
745
+ ): WardenDiagnostic =>
746
+ topoLoadDiagnostic({
747
+ filePath: rootDir,
748
+ message: `No Trails app could be loaded for topo-aware Warden checks. ${cleanDiscoveryMessage(message)} ${WARDEN_TOPO_SELECTION_HINT}`,
749
+ severity: 'error',
750
+ });
751
+
752
+ export interface WardenExpectedAppBinding {
753
+ /** App selector passed through Warden's existing app-resolution path. */
754
+ readonly app: string;
755
+ /** Config-owned stable identity the loaded topo must declare. */
756
+ readonly expectedAppId: string;
757
+ /** App-local root that owns this target's committed lock. */
758
+ readonly rootDir?: string | undefined;
759
+ }
760
+
761
+ interface ResolveTopoTargetsOptions {
762
+ readonly apps?: readonly string[] | undefined;
763
+ readonly expectedAppBindings?:
764
+ | readonly WardenExpectedAppBinding[]
765
+ | undefined;
766
+ readonly rootDir: string;
767
+ readonly strict: boolean;
768
+ }
769
+
770
+ interface ResolvedTopoTargets {
771
+ readonly diagnostics: readonly WardenDiagnostic[];
772
+ readonly preflightError?: ValidationError | undefined;
773
+ readonly topos: readonly WardenTopoTarget[];
774
+ }
775
+
776
+ const expectedAppIdFor = (
777
+ bindings: ReadonlyMap<string, WardenExpectedAppBinding>,
778
+ app: string
779
+ ): string | undefined => bindings.get(app)?.expectedAppId;
780
+
781
+ const configuredTopoTarget = (
782
+ appName: string,
783
+ loaded: Awaited<ReturnType<typeof importTopoFromModulePath>>,
784
+ binding: WardenExpectedAppBinding | undefined
785
+ ): WardenTopoTarget => {
786
+ const target = {
787
+ name: binding?.expectedAppId ?? appName,
788
+ overlays: loaded.overlays,
789
+ ...(binding === undefined ? {} : { requireCommittedLock: true }),
790
+ topo: loaded.topo,
791
+ };
792
+ return binding?.rootDir === undefined
793
+ ? target
794
+ : { ...target, rootDir: binding.rootDir };
795
+ };
796
+
797
+ export const resolveWardenTopoTargets = async ({
798
+ apps,
799
+ expectedAppBindings,
800
+ rootDir,
801
+ strict,
802
+ }: ResolveTopoTargetsOptions): Promise<ResolvedTopoTargets> => {
803
+ const diagnostics: WardenDiagnostic[] = [];
804
+ const topos: WardenTopoTarget[] = [];
805
+ let preflightError: ValidationError | undefined;
806
+ const expectedBindings = new Map(
807
+ (expectedAppBindings ?? []).map((binding) => [binding.app, binding])
808
+ );
809
+
810
+ if (apps !== undefined && apps.length > 0) {
811
+ for (const appName of apps) {
812
+ try {
813
+ const modulePath = resolveNamedAppModulePath(rootDir, appName);
814
+ const loaded = await importTopoFromModulePath(modulePath);
815
+ const expectedBinding = expectedBindings.get(appName);
816
+ const expectedAppId = expectedAppIdFor(expectedBindings, appName);
817
+ if (
818
+ expectedAppBindings !== undefined &&
819
+ loaded.topo.name !== expectedAppId
820
+ ) {
821
+ const error = new ValidationError(
822
+ `Loaded topo "${loaded.topo.name}" does not match the Config-owned identity "${expectedAppId ?? '<missing>'}" for Warden app target "${appName}".`,
823
+ {
824
+ context: {
825
+ actualAppId: loaded.topo.name,
826
+ app: appName,
827
+ expectedAppId,
828
+ reason: 'invalid-binding',
829
+ },
830
+ }
831
+ );
832
+ preflightError ??= error;
833
+ diagnostics.push(
834
+ topoLoadDiagnostic({
835
+ filePath: modulePath,
836
+ message: error.message,
837
+ severity: 'error',
838
+ })
839
+ );
840
+ continue;
841
+ }
842
+ topos.push(configuredTopoTarget(appName, loaded, expectedBinding));
843
+ } catch (error) {
844
+ const message = `Failed to load Trails app "${appName}" for Warden checks: ${errorMessage(error)}`;
845
+ if (expectedAppBindings !== undefined) {
846
+ const expectedAppId = expectedAppIdFor(expectedBindings, appName);
847
+ preflightError ??= new ValidationError(
848
+ `Unable to prove the Config-owned identity "${expectedAppId ?? '<missing>'}" for Warden app target "${appName}". ${message}`,
849
+ {
850
+ cause: error instanceof Error ? error : new Error(String(error)),
851
+ context: {
852
+ app: appName,
853
+ expectedAppId,
854
+ reason: 'invalid-binding',
855
+ },
856
+ }
857
+ );
858
+ }
859
+ diagnostics.push(
860
+ topoLoadDiagnostic({
861
+ filePath: rootDir,
862
+ message,
863
+ severity: 'error',
864
+ })
865
+ );
866
+ }
867
+ }
868
+ return {
869
+ diagnostics,
870
+ ...(preflightError === undefined ? {} : { preflightError }),
871
+ topos,
872
+ };
873
+ }
874
+
875
+ try {
876
+ const modulePath = resolveDiscoveredModulePath(rootDir);
877
+ const loaded = await importTopoFromModulePath(modulePath);
878
+ return {
879
+ diagnostics,
880
+ topos: [
881
+ {
882
+ name: loaded.topo.name,
883
+ overlays: loaded.overlays,
884
+ topo: loaded.topo,
885
+ },
886
+ ],
887
+ };
888
+ } catch (error) {
889
+ if (error instanceof NotFoundError) {
890
+ return {
891
+ diagnostics: strict
892
+ ? [missingTopoDiagnostic(rootDir, error.message)]
893
+ : diagnostics,
894
+ topos,
895
+ };
896
+ }
897
+ if (error instanceof AmbiguousError) {
898
+ return {
899
+ diagnostics: [ambiguousTopoDiagnostic(rootDir, error.message, strict)],
900
+ topos,
901
+ };
902
+ }
903
+ return {
904
+ diagnostics: [
905
+ topoLoadDiagnostic({
906
+ filePath: rootDir,
907
+ message: `Failed to load Trails app for Warden checks: ${errorMessage(error)}`,
908
+ severity: 'error',
909
+ }),
910
+ ],
911
+ topos,
912
+ };
913
+ }
914
+ };
915
+
916
+ const effectiveConfigNeedsTopo = (depth: WardenDepth): boolean =>
917
+ depth === 'topo' || depth === 'all';
918
+
919
+ interface WorkspaceTopoSelection {
920
+ readonly apps?: readonly string[] | undefined;
921
+ readonly diagnostics: readonly WardenDiagnostic[];
922
+ readonly expectedAppBindings?:
923
+ | readonly WardenExpectedAppBinding[]
924
+ | undefined;
925
+ readonly preflightError?: ValidationError | undefined;
926
+ }
927
+
928
+ const resolveWorkspaceTopoSelection = async ({
929
+ apps,
930
+ configPath,
931
+ expectedAppBindings,
932
+ rootDir,
933
+ }: {
934
+ readonly apps?: readonly string[] | undefined;
935
+ readonly configPath?: string | undefined;
936
+ readonly expectedAppBindings?:
937
+ | readonly WardenExpectedAppBinding[]
938
+ | undefined;
939
+ readonly rootDir: string;
940
+ }): Promise<WorkspaceTopoSelection> => {
941
+ if (expectedAppBindings !== undefined || apps !== undefined) {
942
+ return { apps, diagnostics: [], expectedAppBindings };
943
+ }
944
+
945
+ try {
946
+ const identity = await readTrailsProjectIdentity({
947
+ boundaryDir: rootDir,
948
+ configPath,
949
+ startDir: rootDir,
950
+ });
951
+ if (identity.workspace === undefined) {
952
+ return { apps, diagnostics: [], expectedAppBindings };
953
+ }
954
+
955
+ const bindings = identity.apps.map((app) => ({
956
+ app: app.entryPath,
957
+ expectedAppId: app.id,
958
+ rootDir: app.rootDir,
959
+ }));
960
+ return {
961
+ apps: bindings.map((binding) => binding.app),
962
+ diagnostics: [],
963
+ expectedAppBindings: bindings,
964
+ };
965
+ } catch (error) {
966
+ const preflightError = new ValidationError(
967
+ `Failed to inspect Trails project identity for Warden topo selection: ${errorMessage(error)}`,
968
+ {
969
+ cause: error instanceof Error ? error : new Error(String(error)),
970
+ }
971
+ );
972
+ return {
973
+ apps,
974
+ diagnostics: [
975
+ diagnostic({
976
+ filePath: configPath ?? rootDir,
977
+ message: preflightError.message,
978
+ rule: 'warden-config',
979
+ }),
980
+ ],
981
+ expectedAppBindings,
982
+ preflightError,
983
+ };
984
+ }
985
+ };
986
+
987
+ const buildRunOptions = ({
988
+ adapterCheck,
989
+ cli,
990
+ config,
991
+ configPath,
992
+ env,
993
+ fix,
994
+ rootDir,
995
+ topos,
996
+ }: {
997
+ readonly adapterCheck: boolean;
998
+ readonly cli: WardenConfigLayer;
999
+ readonly config?: WardenConfigInput | undefined;
1000
+ readonly configPath?: string | undefined;
1001
+ readonly env: EnvRecord;
1002
+ readonly fix: boolean;
1003
+ readonly rootDir: string;
1004
+ readonly topos: readonly WardenTopoTarget[];
1005
+ }): WardenRunOptions => ({
1006
+ ...cleanUndefined({
1007
+ adapterCheck,
1008
+ apps: cli.apps,
1009
+ config,
1010
+ configPath,
1011
+ depth: cli.depth,
1012
+ drafts: cli.drafts,
1013
+ failOn: cli.failOn,
1014
+ fix,
1015
+ format: cli.format,
1016
+ lock: cli.lock,
1017
+ noLockMutation: cli.noLockMutation,
1018
+ rootDir,
1019
+ scope: cli.scope,
1020
+ topos,
1021
+ }),
1022
+ env,
1023
+ });
1024
+
1025
+ const reportPassed = (report: WardenReport): boolean =>
1026
+ report.errorCount === 0 &&
1027
+ (report.effectiveConfig?.failOn !== 'warning' || report.warnCount === 0) &&
1028
+ !(report.drift?.stale ?? false) &&
1029
+ report.drift?.blockedReason === undefined;
1030
+
1031
+ const mergeDiagnosticsIntoReport = (
1032
+ report: WardenReport,
1033
+ diagnostics: readonly WardenDiagnostic[]
1034
+ ): WardenReport => {
1035
+ if (diagnostics.length === 0) {
1036
+ return report;
1037
+ }
1038
+
1039
+ const mergedDiagnostics = [...diagnostics, ...report.diagnostics];
1040
+ const mergedReport = {
1041
+ ...report,
1042
+ diagnostics: mergedDiagnostics,
1043
+ errorCount: mergedDiagnostics.filter((entry) => entry.severity === 'error')
1044
+ .length,
1045
+ warnCount: mergedDiagnostics.filter((entry) => entry.severity === 'warn')
1046
+ .length,
1047
+ };
1048
+
1049
+ return {
1050
+ ...mergedReport,
1051
+ passed: reportPassed(mergedReport),
1052
+ };
1053
+ };
1054
+
1055
+ export const formatWardenCommandOutput = (report: WardenReport): string => {
1056
+ switch (report.effectiveConfig?.format ?? 'summary') {
1057
+ case 'github': {
1058
+ return formatGitHubAnnotations(report);
1059
+ }
1060
+ case 'json': {
1061
+ return formatJson(report);
1062
+ }
1063
+ case 'summary': {
1064
+ return formatSummary(report);
1065
+ }
1066
+ default: {
1067
+ return formatSummary(report);
1068
+ }
1069
+ }
1070
+ };
1071
+
1072
+ export interface WardenCommandResult {
1073
+ readonly exitCode: 0 | 1;
1074
+ readonly output: string;
1075
+ readonly preflightError?: ValidationError | undefined;
1076
+ readonly report: WardenReport;
1077
+ readonly summary: string;
1078
+ readonly writeStepSummary: boolean;
1079
+ }
1080
+
1081
+ export interface RunWardenCommandOptions {
1082
+ readonly args?: readonly string[] | undefined;
1083
+ readonly cwd: string;
1084
+ readonly env?: EnvRecord | undefined;
1085
+ readonly expectedAppBindings?:
1086
+ | readonly WardenExpectedAppBinding[]
1087
+ | undefined;
1088
+ }
1089
+
1090
+ export const runWardenCommand = async ({
1091
+ args = [],
1092
+ cwd,
1093
+ env = {},
1094
+ expectedAppBindings,
1095
+ }: RunWardenCommandOptions): Promise<WardenCommandResult> => {
1096
+ const parsed = parseWardenCommandArgs(args);
1097
+ const { rootDir } = resolveTrailsProjectRoot({
1098
+ explicitRootDir: parsed.rootDir,
1099
+ startDir: cwd,
1100
+ });
1101
+ const loadedConfig = await loadWardenConfig({
1102
+ configPath: parsed.configPath,
1103
+ env,
1104
+ rootDir,
1105
+ });
1106
+ const preflight = resolveWardenConfig({
1107
+ cli: parsed.cli,
1108
+ config: loadedConfig.config,
1109
+ env,
1110
+ });
1111
+ const needsExpectedBindingPreflight =
1112
+ parsed.fix && expectedAppBindings !== undefined;
1113
+ const needsTopoResolution =
1114
+ effectiveConfigNeedsTopo(preflight.effectiveConfig.depth) ||
1115
+ needsExpectedBindingPreflight;
1116
+ const topoSelection = needsTopoResolution
1117
+ ? await resolveWorkspaceTopoSelection({
1118
+ apps: preflight.effectiveConfig.apps,
1119
+ configPath: loadedConfig.configPath,
1120
+ expectedAppBindings,
1121
+ rootDir,
1122
+ })
1123
+ : { diagnostics: [] };
1124
+ const topoResolution =
1125
+ needsTopoResolution && topoSelection.preflightError === undefined
1126
+ ? await resolveWardenTopoTargets({
1127
+ apps: topoSelection.apps,
1128
+ expectedAppBindings: topoSelection.expectedAppBindings,
1129
+ rootDir,
1130
+ strict: parsed.ci,
1131
+ })
1132
+ : {
1133
+ diagnostics: topoSelection.diagnostics,
1134
+ ...(topoSelection.preflightError === undefined
1135
+ ? {}
1136
+ : { preflightError: topoSelection.preflightError }),
1137
+ topos: [],
1138
+ };
1139
+ const report = await runWarden(
1140
+ buildRunOptions({
1141
+ adapterCheck: parsed.adapterCheck,
1142
+ cli: parsed.cli,
1143
+ config: loadedConfig.config,
1144
+ configPath: loadedConfig.configPath,
1145
+ env,
1146
+ fix: parsed.fix && topoResolution.preflightError === undefined,
1147
+ rootDir,
1148
+ topos: topoResolution.topos,
1149
+ })
1150
+ );
1151
+ const finalReport = mergeDiagnosticsIntoReport(report, [
1152
+ ...parsed.diagnostics,
1153
+ ...loadedConfig.diagnostics,
1154
+ ...topoResolution.diagnostics,
1155
+ ]);
1156
+
1157
+ return {
1158
+ exitCode: finalReport.passed ? 0 : 1,
1159
+ output: formatWardenCommandOutput(finalReport),
1160
+ ...(topoResolution.preflightError === undefined
1161
+ ? {}
1162
+ : { preflightError: topoResolution.preflightError }),
1163
+ report: finalReport,
1164
+ summary: formatSummary(finalReport),
1165
+ writeStepSummary: parsed.ci,
1166
+ };
1167
+ };