@ontrails/warden 1.0.0-beta.4 → 1.0.0-beta.42

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