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

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 +835 -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/cli.ts CHANGED
@@ -5,38 +5,209 @@
5
5
  * and returns a structured report.
6
6
  */
7
7
 
8
- import { resolve } from 'node:path';
8
+ import { isAbsolute, relative, resolve } from 'node:path';
9
9
 
10
- import type { Topo } from '@ontrails/core';
10
+ import { resolveTrailsProjectRoot } from '@ontrails/config';
11
+ import {
12
+ getEntityReferences,
13
+ matchesAnyPathGlob,
14
+ resolveSurfaceOverlayBindings,
15
+ surfaceBindingsFromLockOverlays,
16
+ } from '@ontrails/core';
17
+ import type { SurfaceBindings, Topo } from '@ontrails/core';
18
+ import { deriveTopoGraph } from '@ontrails/topography';
19
+ import type {
20
+ TopoGraph,
21
+ TopoGraphOverlayRegistration,
22
+ } from '@ontrails/topography';
11
23
 
24
+ import type {
25
+ EffectiveWardenConfig,
26
+ WardenConfigInput,
27
+ WardenConfigLayer,
28
+ WardenDepth,
29
+ WardenFailOn,
30
+ WardenFormat,
31
+ WardenScope,
32
+ WardenLockMode,
33
+ } from './config.js';
34
+ import { runWardenAdapterChecks } from './adapter-check.js';
35
+ import { resolveWardenConfig } from './config.js';
36
+ import { isDraftMarkedFile } from './draft.js';
37
+ import { applySafeFixesToSource, hasSafeFixEdits } from './fix.js';
12
38
  import type { DriftResult } from './drift.js';
13
- import { checkDrift } from './drift.js';
39
+ import { checkDrift, staleDriftMessage } from './drift.js';
40
+ import { loadProjectWardenRules } from './project-rules.js';
41
+ import type { ProjectWardenRules } from './project-rules.js';
42
+ import {
43
+ collectProjectDocumentationImportResolutions,
44
+ collectProjectExportedSymbolDefinitions,
45
+ collectProjectImportResolutions,
46
+ collectPublicWorkspaces,
47
+ } from './project-context.js';
48
+ import {
49
+ collectComposeTargetTrailIds,
50
+ collectTrailIntentsById,
51
+ } from './rules/source/composition.js';
52
+ import {
53
+ collectEntityDefinitionIds,
54
+ collectEntityReferenceTargetsByName,
55
+ } from './rules/source/entities.js';
56
+ import { collectResourceDefinitionIds } from './rules/source/resources.js';
57
+ import {
58
+ collectOnTargetSignalIds as collectOnTargetSignalIdsFromAst,
59
+ collectSignalDefinitionIds,
60
+ } from './rules/source/signals.js';
61
+ import {
62
+ collectCrudTableIds as collectCrudTableIdsFromAst,
63
+ collectReconcileTableIds as collectReconcileTableIdsFromAst,
64
+ } from './rules/source/stores.js';
65
+ import { findTrailDefinitions, parse } from '@ontrails/source';
66
+ import { collectFileCrudCoverage } from './rules/incomplete-crud.js';
67
+ import { wardenRules, wardenTopoRules } from './rules/index.js';
68
+ import { getWardenRuleMetadata } from './rules/metadata.js';
14
69
  import {
15
- findConfigProperty,
16
- findTrailDefinitions,
17
- parse,
18
- walk,
19
- } from './rules/ast.js';
20
- import { wardenRules } from './rules/index.js';
70
+ isWardenDevPermitTestScanTarget,
71
+ isWardenSourceScanTarget,
72
+ } from './rules/scan.js';
21
73
  import type {
74
+ AuthoredMcpSurfaceBindingSet,
22
75
  ProjectAwareWardenRule,
23
76
  ProjectContext,
77
+ TopoAwareWardenRule,
24
78
  WardenDiagnostic,
79
+ WardenExportedSymbolDefinition,
80
+ WardenGuidanceLink,
25
81
  WardenRule,
82
+ WardenRuleTier,
26
83
  } from './rules/types.js';
84
+ import type { WardenImportResolution } from './resolve.js';
27
85
 
28
86
  /**
29
- * Options for the warden CLI runner.
87
+ * Resolved topo input for Warden runs that govern multiple apps.
30
88
  */
31
- export interface WardenOptions {
89
+ export interface WardenTopoTarget {
90
+ /** Optional precomputed topo graph, including graph-only audit annotations. */
91
+ readonly graph?: TopoGraph | undefined;
92
+ /** Stable app/topo label used to tag topo-aware diagnostics. */
93
+ readonly name?: string | undefined;
94
+ /**
95
+ * App-module overlay registrations collected through the shared
96
+ * `resolveTrailsOverlays` channel, so fresh drift and topo-aware rule
97
+ * derivations carry the same overlays the committed lock embeds.
98
+ */
99
+ readonly overlays?: readonly TopoGraphOverlayRegistration[] | undefined;
100
+ /** Resolved topo module to inspect. */
101
+ readonly topo: Topo;
102
+ }
103
+
104
+ /**
105
+ * Options for the shared Warden runner.
106
+ */
107
+ export interface WardenRunOptions {
32
108
  /** Root directory to scan for TypeScript files. Defaults to cwd. */
33
109
  readonly rootDir?: string | undefined;
110
+ /** Warden config section from `trails.config.ts`, if already loaded. */
111
+ readonly config?: WardenConfigInput | undefined;
112
+ /** CLI/config-layer app names carried through shared resolution. */
113
+ readonly apps?: readonly string[] | undefined;
114
+ /** Include shared adapter authoring checks as Warden diagnostics. */
115
+ readonly adapterCheck?: boolean | undefined;
116
+ /** Cumulative analysis depth for the final M1 surfaces. */
117
+ readonly depth?: WardenDepth | undefined;
118
+ /** Draft-state handling mode for final M1 surfaces. */
119
+ readonly drafts?: EffectiveWardenConfig['drafts'] | undefined;
120
+ /** Failure threshold used to compute `report.passed`. */
121
+ readonly failOn?: WardenFailOn | undefined;
122
+ /**
123
+ * Apply safe source fixes among the run's diagnostics, writing changed files.
124
+ *
125
+ * Only `safety: 'safe'` fixes with concrete edits are applied; review-required,
126
+ * edit-less, and topo diagnostics stay reported but unapplied.
127
+ */
128
+ readonly fix?: boolean | undefined;
129
+ /** Output format requested by the caller. */
130
+ readonly format?: WardenFormat | undefined;
131
+ /** Root-relative source paths that Warden should not govern. */
132
+ readonly scope?: WardenScope | undefined;
133
+ /** Lockfile mode requested by the caller. */
134
+ readonly lock?: WardenLockMode | undefined;
135
+ /** Suppress lockfile mutation for CI/pre-push callers. */
136
+ readonly noLockMutation?: boolean | undefined;
137
+ /** Environment layer for config resolution. Pass `process.env` at process boundaries. */
138
+ readonly env?: Record<string, string | undefined> | undefined;
34
139
  /** Only run lint rules, skip drift detection */
35
140
  readonly lintOnly?: boolean | undefined;
36
141
  /** Only run drift detection, skip lint rules */
37
142
  readonly driftOnly?: boolean | undefined;
38
- /** App topology for drift detection. When provided, enables real surface lock comparison. */
143
+ /**
144
+ * Run a single Warden tier. Defaults to all lint tiers plus drift.
145
+ *
146
+ * Selecting a non-drift tier skips drift detection; selecting `drift` skips
147
+ * lint rule dispatch. `lintOnly` and `driftOnly` remain compatibility shims.
148
+ */
149
+ readonly tier?: WardenRuleTier | undefined;
150
+ /**
151
+ * Load project-local Warden rules from `.trails/rules.ts` or `.trails/rules/`.
152
+ *
153
+ * Enabled by default for normal runs so a Trails app can carry migration or
154
+ * repo-local governance with the project instead of shipping it from
155
+ * `@ontrails/warden`. Set to `false` for narrow tests or embedders that need
156
+ * only built-in and explicitly provided rules.
157
+ */
158
+ readonly projectRules?: boolean | undefined;
159
+ /**
160
+ * App topology for drift detection. When provided, enables real topology
161
+ * drift comparison and unlocks the topo-aware rule dispatch path.
162
+ *
163
+ * @remarks
164
+ * Topo-aware rules (both built-in `wardenTopoRules` and `extraTopoRules`)
165
+ * only fire when a `Topo` is supplied. Runs without a topo silently skip
166
+ * topo-aware dispatch — callers that depend on a topo-aware rule firing
167
+ * must pass `topo` explicitly.
168
+ */
39
169
  readonly topo?: Topo | undefined;
170
+ /**
171
+ * Multiple resolved topos to govern in one invocation.
172
+ *
173
+ * Source/project rules run once; topo-aware rules run once per target.
174
+ */
175
+ readonly topos?: readonly WardenTopoTarget[] | undefined;
176
+ /**
177
+ * Extra topo-aware rules to run in addition to the built-in registry.
178
+ *
179
+ * Primarily a test hook — production callers should register rules via
180
+ * `wardenTopoRules` in `rules/index.ts`. These rules are only invoked
181
+ * when `topo` is also supplied (see `topo` remarks).
182
+ */
183
+ readonly extraTopoRules?: readonly TopoAwareWardenRule[] | undefined;
184
+ /**
185
+ * Extra source rules to run in addition to the built-in registry.
186
+ *
187
+ * Primarily a test hook — production callers should register durable rules
188
+ * via `wardenRules` in `rules/index.ts`.
189
+ */
190
+ readonly extraSourceRules?: readonly WardenRule[] | undefined;
191
+ }
192
+
193
+ /** Backwards-compatible name for older consumers. */
194
+ export type WardenOptions = WardenRunOptions;
195
+
196
+ /**
197
+ * Aggregate outcome of a `--fix` pass over a run's diagnostics.
198
+ */
199
+ export interface WardenFixSummary {
200
+ /** Diagnostics whose safe fix was applied to source. */
201
+ readonly applied: number;
202
+ /** Source files rewritten with patched content. */
203
+ readonly filesChanged: number;
204
+ /** Diagnostics carrying fix metadata left unapplied (review or edit-less). */
205
+ readonly skipped: number;
206
+ }
207
+
208
+ export interface WardenFixApplication extends WardenFixSummary {
209
+ /** Diagnostics removed from the final report because their safe fix applied. */
210
+ readonly appliedDiagnostics: readonly WardenDiagnostic[];
40
211
  }
41
212
 
42
213
  /**
@@ -53,33 +224,145 @@ export interface WardenReport {
53
224
  readonly drift: DriftResult | null;
54
225
  /** Whether the warden run passed (no errors, no drift) */
55
226
  readonly passed: boolean;
227
+ /** Effective shared config consumed by this run. */
228
+ readonly effectiveConfig?: EffectiveWardenConfig | undefined;
229
+ /** Resolved topo/app labels governed by this run. */
230
+ readonly topoNames?: readonly string[] | undefined;
231
+ /** Safe-fix application summary, present only when a `--fix` pass ran. */
232
+ readonly fixes?: WardenFixSummary | undefined;
56
233
  }
57
234
 
58
235
  /**
59
- * Collect all .ts files under a directory, excluding node_modules, dist, and .git.
236
+ * Collect Warden scan targets under a directory, excluding generated and test
237
+ * surfaces that should not contribute most committed-source diagnostics.
60
238
  */
61
- const isSourceFile = (match: string): boolean =>
62
- !match.endsWith('.d.ts') &&
63
- !match.startsWith('node_modules/') &&
64
- !match.startsWith('dist/') &&
65
- !match.startsWith('.git/') &&
66
- !match.includes('__tests__/') &&
67
- !match.includes('__test__/') &&
68
- !match.endsWith('.test.ts') &&
69
- !match.endsWith('.spec.ts');
70
-
71
- const collectTsFiles = (dir: string): readonly string[] => {
239
+ const collectFilesMatching = (
240
+ dir: string,
241
+ pattern: string,
242
+ dot = false
243
+ ): readonly string[] => {
244
+ const glob = new Bun.Glob(pattern);
245
+ let matches: IterableIterator<string>;
246
+ try {
247
+ matches = glob.scanSync({ cwd: dir, dot, onlyFiles: true });
248
+ } catch {
249
+ return [];
250
+ }
251
+
252
+ const files: string[] = [];
253
+ for (const match of matches) {
254
+ if (isWardenSourceScanTarget(match)) {
255
+ files.push(`${dir}/${match}`);
256
+ }
257
+ }
258
+ return files;
259
+ };
260
+
261
+ const collectTsFiles = (dir: string): readonly string[] =>
262
+ collectFilesMatching(dir, '**/*.ts');
263
+
264
+ const draftModeIncludesFile = (
265
+ filePath: string,
266
+ drafts: EffectiveWardenConfig['drafts']
267
+ ): boolean => {
268
+ const isDraftFile = isDraftMarkedFile(filePath);
269
+ if (drafts === 'exclude') {
270
+ return !isDraftFile;
271
+ }
272
+ if (drafts === 'only') {
273
+ return isDraftFile;
274
+ }
275
+ return true;
276
+ };
277
+
278
+ const filterSourceFilesByDraftMode = (
279
+ sourceFiles: readonly SourceFile[],
280
+ drafts: EffectiveWardenConfig['drafts']
281
+ ): readonly SourceFile[] =>
282
+ drafts === 'include'
283
+ ? sourceFiles
284
+ : sourceFiles.filter((sourceFile) =>
285
+ draftModeIncludesFile(sourceFile.filePath, drafts)
286
+ );
287
+
288
+ const rootRelativeScopePath = (rootDir: string, filePath: string): string =>
289
+ relative(rootDir, filePath).replaceAll('\\', '/');
290
+
291
+ const filterSourceFilesByScope = (
292
+ sourceFiles: readonly SourceFile[],
293
+ rootDir: string,
294
+ scope: WardenScope
295
+ ): readonly SourceFile[] => {
296
+ if (scope.exclude.length === 0) {
297
+ return sourceFiles;
298
+ }
299
+
300
+ return sourceFiles.filter(
301
+ (sourceFile) =>
302
+ !matchesAnyPathGlob(
303
+ rootRelativeScopePath(rootDir, sourceFile.filePath),
304
+ scope.exclude
305
+ )
306
+ );
307
+ };
308
+
309
+ const EMPTY_WARDEN_SCOPE: WardenScope = { exclude: [] };
310
+
311
+ const collectDevPermitTestFiles = (dir: string): readonly string[] => {
72
312
  const glob = new Bun.Glob('**/*.ts');
73
313
  let matches: IterableIterator<string>;
74
314
  try {
75
- matches = glob.scanSync({ cwd: dir, dot: false, onlyFiles: true });
315
+ matches = glob.scanSync({ cwd: dir, onlyFiles: true });
76
316
  } catch {
77
317
  return [];
78
318
  }
79
319
 
80
320
  const files: string[] = [];
81
321
  for (const match of matches) {
82
- if (isSourceFile(match)) {
322
+ if (isWardenDevPermitTestScanTarget(match)) {
323
+ files.push(`${dir}/${match}`);
324
+ }
325
+ }
326
+ return files;
327
+ };
328
+
329
+ const collectTextScanFiles = (dir: string): readonly string[] => [
330
+ ...collectFilesMatching(dir, '**/*.sh', true),
331
+ ...collectFilesMatching(dir, '**/*.bash', true),
332
+ ...collectFilesMatching(dir, '**/*.zsh', true),
333
+ ...collectFilesMatching(dir, '**/*.yml', true),
334
+ ...collectFilesMatching(dir, '**/*.yaml', true),
335
+ ...collectFilesMatching(dir, '**/package.json', true),
336
+ ];
337
+
338
+ const isDocumentationScanTarget = (match: string): boolean => {
339
+ if (match === 'README.md') {
340
+ return true;
341
+ }
342
+ if (/^(?:packages|adapters|apps)\/[^/]+\/README\.md$/.test(match)) {
343
+ return true;
344
+ }
345
+ return (
346
+ match.startsWith('docs/') &&
347
+ !match.startsWith('docs/adr/') &&
348
+ !match.startsWith('docs/migration/') &&
349
+ !match.startsWith('docs/releases/') &&
350
+ match.endsWith('.md')
351
+ );
352
+ };
353
+
354
+ const collectDocumentationFiles = (dir: string): readonly string[] => {
355
+ const glob = new Bun.Glob('**/*.md');
356
+ let matches: IterableIterator<string>;
357
+ try {
358
+ matches = glob.scanSync({ cwd: dir, onlyFiles: true });
359
+ } catch {
360
+ return [];
361
+ }
362
+
363
+ const files: string[] = [];
364
+ for (const match of matches) {
365
+ if (isWardenSourceScanTarget(match) && isDocumentationScanTarget(match)) {
83
366
  files.push(`${dir}/${match}`);
84
367
  }
85
368
  }
@@ -88,9 +371,154 @@ const collectTsFiles = (dir: string): readonly string[] => {
88
371
 
89
372
  interface SourceFile {
90
373
  readonly filePath: string;
374
+ readonly kind: 'documentation' | 'text' | 'typescript';
91
375
  readonly sourceCode: string;
92
376
  }
93
377
 
378
+ interface MutableProjectContext {
379
+ authoredMcpSurfaceBindingSets:
380
+ | readonly AuthoredMcpSurfaceBindingSet[]
381
+ | undefined;
382
+ entityReferencesByName: Map<string, Set<string>>;
383
+ crudTableIds: Set<string>;
384
+ composeTargetTrailIds: Set<string>;
385
+ crudCoverageByEntity: Map<string, Set<string>>;
386
+ knownEntityIds: Set<string>;
387
+ knownResourceIds: Set<string>;
388
+ knownSignalIds: Set<string>;
389
+ knownTrailIds: Set<string>;
390
+ topoTrailIds: Set<string>;
391
+ importResolutionsByFile: Map<string, readonly WardenImportResolution[]>;
392
+ exportedSymbolDefinitionsByName: Map<
393
+ string,
394
+ readonly WardenExportedSymbolDefinition[]
395
+ >;
396
+ documentedImportResolutionsByFile: Map<
397
+ string,
398
+ readonly WardenImportResolution[]
399
+ >;
400
+ onTargetSignalIds: Set<string>;
401
+ publicWorkspaces: ReturnType<typeof collectPublicWorkspaces>;
402
+ reconcileTableIds: Set<string>;
403
+ trailIntentsById: Map<string, 'destroy' | 'read' | 'write'>;
404
+ }
405
+
406
+ const createMutableProjectContext = (): MutableProjectContext => ({
407
+ authoredMcpSurfaceBindingSets: undefined,
408
+ composeTargetTrailIds: new Set<string>(),
409
+ crudCoverageByEntity: new Map<string, Set<string>>(),
410
+ crudTableIds: new Set<string>(),
411
+ documentedImportResolutionsByFile: new Map<
412
+ string,
413
+ readonly WardenImportResolution[]
414
+ >(),
415
+ entityReferencesByName: new Map<string, Set<string>>(),
416
+ exportedSymbolDefinitionsByName: new Map(),
417
+ importResolutionsByFile: new Map<string, readonly WardenImportResolution[]>(),
418
+ knownEntityIds: new Set<string>(),
419
+ knownResourceIds: new Set<string>(),
420
+ knownSignalIds: new Set<string>(),
421
+ knownTrailIds: new Set<string>(),
422
+ onTargetSignalIds: new Set<string>(),
423
+ publicWorkspaces: new Map(),
424
+ reconcileTableIds: new Set<string>(),
425
+ topoTrailIds: new Set<string>(),
426
+ trailIntentsById: new Map<string, 'destroy' | 'read' | 'write'>(),
427
+ });
428
+
429
+ const addEntityReferenceTargets = (
430
+ context: MutableProjectContext,
431
+ entityName: string,
432
+ targets: readonly string[]
433
+ ): void => {
434
+ const existing = context.entityReferencesByName.get(entityName);
435
+ if (existing) {
436
+ for (const target of targets) {
437
+ existing.add(target);
438
+ }
439
+ return;
440
+ }
441
+
442
+ context.entityReferencesByName.set(entityName, new Set(targets));
443
+ };
444
+
445
+ const toProjectContext = (context: MutableProjectContext): ProjectContext => ({
446
+ ...(context.authoredMcpSurfaceBindingSets === undefined
447
+ ? {}
448
+ : { authoredMcpSurfaceBindingSets: context.authoredMcpSurfaceBindingSets }),
449
+ ...(context.entityReferencesByName.size > 0
450
+ ? {
451
+ entityReferencesByName: new Map(
452
+ [...context.entityReferencesByName.entries()].map(
453
+ ([name, targets]) => [name, [...targets]]
454
+ )
455
+ ),
456
+ }
457
+ : {}),
458
+ ...(context.crudTableIds.size > 0
459
+ ? { crudTableIds: context.crudTableIds }
460
+ : {}),
461
+ ...(context.crudCoverageByEntity.size > 0
462
+ ? {
463
+ crudCoverageByEntity: new Map(
464
+ [...context.crudCoverageByEntity.entries()].map(
465
+ ([entityId, operations]) => [
466
+ entityId,
467
+ new Set(operations) as ReadonlySet<string>,
468
+ ]
469
+ )
470
+ ),
471
+ }
472
+ : {}),
473
+ composeTargetTrailIds: context.composeTargetTrailIds,
474
+ knownEntityIds: context.knownEntityIds,
475
+ knownResourceIds: context.knownResourceIds,
476
+ knownSignalIds: context.knownSignalIds,
477
+ knownTrailIds: context.knownTrailIds,
478
+ ...(context.topoTrailIds.size > 0
479
+ ? { topoTrailIds: context.topoTrailIds }
480
+ : {}),
481
+ ...(context.importResolutionsByFile.size > 0
482
+ ? { importResolutionsByFile: context.importResolutionsByFile }
483
+ : {}),
484
+ ...(context.documentedImportResolutionsByFile.size > 0
485
+ ? {
486
+ documentedImportResolutionsByFile:
487
+ context.documentedImportResolutionsByFile,
488
+ }
489
+ : {}),
490
+ ...(context.exportedSymbolDefinitionsByName.size > 0
491
+ ? {
492
+ exportedSymbolDefinitionsByName:
493
+ context.exportedSymbolDefinitionsByName,
494
+ }
495
+ : {}),
496
+ ...(context.onTargetSignalIds.size > 0
497
+ ? { onTargetSignalIds: context.onTargetSignalIds }
498
+ : {}),
499
+ ...(context.publicWorkspaces.size > 0
500
+ ? { publicWorkspaces: context.publicWorkspaces }
501
+ : {}),
502
+ ...(context.reconcileTableIds.size > 0
503
+ ? { reconcileTableIds: context.reconcileTableIds }
504
+ : {}),
505
+ trailIntentsById: context.trailIntentsById,
506
+ });
507
+
508
+ const collectKnownEntityIds = (
509
+ sourceCode: string,
510
+ filePath: string,
511
+ knownEntityIds: Set<string>
512
+ ): void => {
513
+ const ast = parse(filePath, sourceCode);
514
+ if (!ast) {
515
+ return;
516
+ }
517
+ for (const id of collectEntityDefinitionIds(ast)) {
518
+ knownEntityIds.add(id);
519
+ }
520
+ };
521
+
94
522
  const collectKnownTrailIds = (
95
523
  sourceCode: string,
96
524
  filePath: string,
@@ -105,30 +533,122 @@ const collectKnownTrailIds = (
105
533
  }
106
534
  };
107
535
 
108
- const collectDetourTargetTrailIds = (
536
+ const collectComposedTrailIds = (
109
537
  sourceCode: string,
110
538
  filePath: string,
111
- detourTargetTrailIds: Set<string>
539
+ composeTargetTrailIds: Set<string>
112
540
  ): void => {
113
541
  const ast = parse(filePath, sourceCode);
114
542
  if (!ast) {
115
543
  return;
116
544
  }
117
- for (const def of findTrailDefinitions(ast)) {
118
- const detoursProp = findConfigProperty(def.config, 'detours');
119
- if (!detoursProp) {
120
- continue;
545
+ for (const id of collectComposeTargetTrailIds(ast, sourceCode)) {
546
+ composeTargetTrailIds.add(id);
547
+ }
548
+ };
549
+
550
+ const collectKnownResourceIds = (
551
+ sourceCode: string,
552
+ filePath: string,
553
+ knownResourceIds: Set<string>
554
+ ): void => {
555
+ const ast = parse(filePath, sourceCode);
556
+ if (!ast) {
557
+ return;
558
+ }
559
+ for (const id of collectResourceDefinitionIds(ast)) {
560
+ knownResourceIds.add(id);
561
+ }
562
+ };
563
+
564
+ const collectKnownSignalIds = (
565
+ sourceCode: string,
566
+ filePath: string,
567
+ knownSignalIds: Set<string>
568
+ ): void => {
569
+ const ast = parse(filePath, sourceCode);
570
+ if (!ast) {
571
+ return;
572
+ }
573
+ for (const id of collectSignalDefinitionIds(ast)) {
574
+ knownSignalIds.add(id);
575
+ }
576
+ };
577
+
578
+ const collectTrailIntents = (
579
+ sourceCode: string,
580
+ filePath: string,
581
+ trailIntentsById: Map<string, 'destroy' | 'read' | 'write'>
582
+ ): void => {
583
+ const ast = parse(filePath, sourceCode);
584
+ if (!ast) {
585
+ return;
586
+ }
587
+ for (const [id, intent] of collectTrailIntentsById(ast)) {
588
+ trailIntentsById.set(id, intent);
589
+ }
590
+ };
591
+
592
+ const collectCrudTableIds = (
593
+ sourceCode: string,
594
+ filePath: string,
595
+ crudTableIds: Set<string>
596
+ ): void => {
597
+ const ast = parse(filePath, sourceCode);
598
+ if (!ast) {
599
+ return;
600
+ }
601
+ for (const id of collectCrudTableIdsFromAst(ast)) {
602
+ crudTableIds.add(id);
603
+ }
604
+ };
605
+
606
+ const collectOnTargetSignalIds = (
607
+ sourceCode: string,
608
+ filePath: string,
609
+ onTargetSignalIds: Set<string>
610
+ ): void => {
611
+ const ast = parse(filePath, sourceCode);
612
+ if (!ast) {
613
+ return;
614
+ }
615
+ for (const id of collectOnTargetSignalIdsFromAst(ast, sourceCode)) {
616
+ onTargetSignalIds.add(id);
617
+ }
618
+ };
619
+
620
+ const collectCrudCoverageByEntity = (
621
+ sourceCode: string,
622
+ filePath: string,
623
+ coverageByEntity: Map<string, Set<string>>
624
+ ): void => {
625
+ const ast = parse(filePath, sourceCode);
626
+ if (!ast) {
627
+ return;
628
+ }
629
+ for (const [entityId, operations] of collectFileCrudCoverage(
630
+ ast,
631
+ sourceCode
632
+ )) {
633
+ const bucket = coverageByEntity.get(entityId) ?? new Set<string>();
634
+ for (const operation of operations) {
635
+ bucket.add(operation);
121
636
  }
122
- // Walk the detours value for string literals that look like trail IDs
123
- walk(detoursProp, (node) => {
124
- if (node.type !== 'Literal') {
125
- return;
126
- }
127
- const val = (node as unknown as { value?: string }).value;
128
- if (val && val.includes('.')) {
129
- detourTargetTrailIds.add(val);
130
- }
131
- });
637
+ coverageByEntity.set(entityId, bucket);
638
+ }
639
+ };
640
+
641
+ const collectReconcileTableIds = (
642
+ sourceCode: string,
643
+ filePath: string,
644
+ reconcileTableIds: Set<string>
645
+ ): void => {
646
+ const ast = parse(filePath, sourceCode);
647
+ if (!ast) {
648
+ return;
649
+ }
650
+ for (const id of collectReconcileTableIdsFromAst(ast)) {
651
+ reconcileTableIds.add(id);
132
652
  }
133
653
  };
134
654
 
@@ -141,6 +661,43 @@ const loadSourceFiles = async (
141
661
  try {
142
662
  sourceFiles.push({
143
663
  filePath,
664
+ kind: 'typescript',
665
+ sourceCode: await Bun.file(filePath).text(),
666
+ });
667
+ } catch {
668
+ continue;
669
+ }
670
+ }
671
+
672
+ for (const filePath of collectTextScanFiles(rootDir)) {
673
+ try {
674
+ sourceFiles.push({
675
+ filePath,
676
+ kind: 'text',
677
+ sourceCode: await Bun.file(filePath).text(),
678
+ });
679
+ } catch {
680
+ continue;
681
+ }
682
+ }
683
+
684
+ for (const filePath of collectDocumentationFiles(rootDir)) {
685
+ try {
686
+ sourceFiles.push({
687
+ filePath,
688
+ kind: 'documentation',
689
+ sourceCode: await Bun.file(filePath).text(),
690
+ });
691
+ } catch {
692
+ continue;
693
+ }
694
+ }
695
+
696
+ for (const filePath of collectDevPermitTestFiles(rootDir)) {
697
+ try {
698
+ sourceFiles.push({
699
+ filePath,
700
+ kind: 'text',
144
701
  sourceCode: await Bun.file(filePath).text(),
145
702
  });
146
703
  } catch {
@@ -151,72 +708,503 @@ const loadSourceFiles = async (
151
708
  return sourceFiles;
152
709
  };
153
710
 
154
- const buildProjectContextFromTopo = (appTopo: Topo): ProjectContext => {
155
- const knownTrailIds = new Set<string>(appTopo.trails.keys());
711
+ const collectTopoKnownIds = (
712
+ appTopo: Topo,
713
+ context: MutableProjectContext
714
+ ): void => {
715
+ for (const name of appTopo.entities.keys()) {
716
+ context.knownEntityIds.add(name);
717
+ }
718
+
719
+ for (const id of appTopo.trails.keys()) {
720
+ context.knownTrailIds.add(id);
721
+ context.topoTrailIds.add(id);
722
+ }
723
+
724
+ for (const id of appTopo.resources.keys()) {
725
+ context.knownResourceIds.add(id);
726
+ }
727
+
728
+ for (const id of appTopo.signals.keys()) {
729
+ context.knownSignalIds.add(id);
730
+ }
731
+ };
732
+
733
+ const collectTopoComposesAndIntents = (
734
+ appTopo: Topo,
735
+ context: MutableProjectContext
736
+ ): void => {
737
+ for (const trail of appTopo.trails.values()) {
738
+ context.trailIntentsById.set(trail.id, trail.intent);
739
+ for (const composedTrailId of trail.composes) {
740
+ context.composeTargetTrailIds.add(composedTrailId);
741
+ }
742
+ }
743
+ };
744
+
745
+ const collectTopoEntityReferences = (
746
+ appTopo: Topo,
747
+ context: MutableProjectContext
748
+ ): void => {
749
+ for (const entity of appTopo.listEntities()) {
750
+ addEntityReferenceTargets(
751
+ context,
752
+ entity.name,
753
+ getEntityReferences(entity).map((reference) => reference.entity)
754
+ );
755
+ }
756
+ };
757
+
758
+ const collectTopoTrailContext = (
759
+ appTopo: Topo,
760
+ context: MutableProjectContext
761
+ ): void => {
762
+ collectTopoKnownIds(appTopo, context);
763
+ collectTopoComposesAndIntents(appTopo, context);
764
+ collectTopoEntityReferences(appTopo, context);
765
+ };
156
766
 
157
- const detourTargetTrailIds = new Set<string>();
158
- for (const t of appTopo.trails.values()) {
159
- const detours = (t as unknown as Record<string, unknown>)['detours'] as
160
- | Readonly<Record<string, readonly string[]>>
161
- | undefined;
162
- if (!detours) {
767
+ const collectFileKnownIds = (
768
+ sourceFile: SourceFile,
769
+ context: MutableProjectContext
770
+ ): void => {
771
+ collectKnownEntityIds(
772
+ sourceFile.sourceCode,
773
+ sourceFile.filePath,
774
+ context.knownEntityIds
775
+ );
776
+ collectKnownTrailIds(
777
+ sourceFile.sourceCode,
778
+ sourceFile.filePath,
779
+ context.knownTrailIds
780
+ );
781
+ collectKnownResourceIds(
782
+ sourceFile.sourceCode,
783
+ sourceFile.filePath,
784
+ context.knownResourceIds
785
+ );
786
+ collectKnownSignalIds(
787
+ sourceFile.sourceCode,
788
+ sourceFile.filePath,
789
+ context.knownSignalIds
790
+ );
791
+ };
792
+
793
+ const collectFileTrailRelationships = (
794
+ sourceFile: SourceFile,
795
+ context: MutableProjectContext
796
+ ): void => {
797
+ collectComposedTrailIds(
798
+ sourceFile.sourceCode,
799
+ sourceFile.filePath,
800
+ context.composeTargetTrailIds
801
+ );
802
+ collectTrailIntents(
803
+ sourceFile.sourceCode,
804
+ sourceFile.filePath,
805
+ context.trailIntentsById
806
+ );
807
+ };
808
+
809
+ const collectFileSupplementalProjectContext = (
810
+ sourceFile: SourceFile,
811
+ context: MutableProjectContext
812
+ ): void => {
813
+ collectCrudTableIds(
814
+ sourceFile.sourceCode,
815
+ sourceFile.filePath,
816
+ context.crudTableIds
817
+ );
818
+ collectOnTargetSignalIds(
819
+ sourceFile.sourceCode,
820
+ sourceFile.filePath,
821
+ context.onTargetSignalIds
822
+ );
823
+ collectReconcileTableIds(
824
+ sourceFile.sourceCode,
825
+ sourceFile.filePath,
826
+ context.reconcileTableIds
827
+ );
828
+ collectCrudCoverageByEntity(
829
+ sourceFile.sourceCode,
830
+ sourceFile.filePath,
831
+ context.crudCoverageByEntity
832
+ );
833
+ };
834
+
835
+ const collectFileProjectContext = (
836
+ sourceFile: SourceFile,
837
+ context: MutableProjectContext
838
+ ): void => {
839
+ collectFileKnownIds(sourceFile, context);
840
+ collectFileTrailRelationships(sourceFile, context);
841
+ collectFileSupplementalProjectContext(sourceFile, context);
842
+ };
843
+
844
+ const collectFileEntityReferences = (
845
+ sourceFile: SourceFile,
846
+ context: MutableProjectContext
847
+ ): void => {
848
+ const ast = parse(sourceFile.filePath, sourceFile.sourceCode);
849
+ if (!ast) {
850
+ return;
851
+ }
852
+
853
+ const referencesByName = collectEntityReferenceTargetsByName(
854
+ ast,
855
+ context.knownEntityIds
856
+ );
857
+ for (const [entityName, targets] of referencesByName) {
858
+ addEntityReferenceTargets(context, entityName, targets);
859
+ }
860
+ };
861
+
862
+ const collectFileImportResolutions = (
863
+ rootDir: string,
864
+ sourceFiles: readonly SourceFile[],
865
+ context: MutableProjectContext
866
+ ): void => {
867
+ const resolutionsByFile = collectProjectImportResolutions({
868
+ publicWorkspaces: context.publicWorkspaces,
869
+ rootDir,
870
+ sourceFiles,
871
+ });
872
+ for (const [filePath, resolutions] of resolutionsByFile) {
873
+ context.importResolutionsByFile.set(filePath, resolutions);
874
+ }
875
+ };
876
+
877
+ const collectFileDocumentedImportResolutions = (
878
+ rootDir: string,
879
+ sourceFiles: readonly SourceFile[],
880
+ context: MutableProjectContext
881
+ ): void => {
882
+ const resolutionsByFile = collectProjectDocumentationImportResolutions({
883
+ publicWorkspaces: context.publicWorkspaces,
884
+ rootDir,
885
+ sourceFiles,
886
+ });
887
+ for (const [filePath, resolutions] of resolutionsByFile) {
888
+ context.documentedImportResolutionsByFile.set(filePath, resolutions);
889
+ }
890
+ };
891
+
892
+ const collectFileExportedSymbolDefinitions = (
893
+ rootDir: string,
894
+ sourceFiles: readonly SourceFile[],
895
+ context: MutableProjectContext
896
+ ): void => {
897
+ const definitionsByName = collectProjectExportedSymbolDefinitions({
898
+ publicWorkspaces: context.publicWorkspaces,
899
+ rootDir,
900
+ sourceFiles,
901
+ });
902
+ for (const [name, definitions] of definitionsByName) {
903
+ context.exportedSymbolDefinitionsByName.set(name, definitions);
904
+ }
905
+ };
906
+
907
+ /**
908
+ * Resolve per-app authored `mcp` surface bindings across the run's topo
909
+ * targets, so project-aware source rules can compare call-site surface
910
+ * options against the authored default of the app they belong to.
911
+ *
912
+ * Prefers the serialized graph overlays when a target carries a precomputed
913
+ * graph; otherwise reads the app-module overlay registrations. Invalid
914
+ * overlays are skipped here — `surface-overlay-coherence` reports them on
915
+ * the topo-aware path.
916
+ */
917
+ const collectAuthoredMcpSurfaceBindingSets = (
918
+ topoTargets: readonly WardenTopoTarget[]
919
+ ): readonly AuthoredMcpSurfaceBindingSet[] | undefined => {
920
+ const sets: AuthoredMcpSurfaceBindingSet[] = [];
921
+ for (const target of topoTargets) {
922
+ let bindings: SurfaceBindings | undefined;
923
+ try {
924
+ const graphOverlays = target.graph?.overlays;
925
+ bindings =
926
+ graphOverlays === undefined
927
+ ? resolveSurfaceOverlayBindings(target.overlays)?.mcp
928
+ : surfaceBindingsFromLockOverlays(graphOverlays)?.mcp;
929
+ } catch {
163
930
  continue;
164
931
  }
165
- for (const targets of Object.values(detours)) {
166
- for (const id of targets) {
167
- detourTargetTrailIds.add(id);
168
- }
932
+ if (bindings === undefined) {
933
+ continue;
169
934
  }
935
+ sets.push({
936
+ appName: target.name ?? target.topo.name,
937
+ bindings,
938
+ trailIds: [...target.topo.trails.keys()],
939
+ });
170
940
  }
171
-
172
- return { detourTargetTrailIds, knownTrailIds };
941
+ return sets.length > 0 ? sets : undefined;
173
942
  };
174
943
 
175
- const buildProjectContextFromFiles = (
176
- sourceFiles: readonly SourceFile[]
944
+ const buildProjectContext = (
945
+ sourceFiles: readonly SourceFile[],
946
+ rootDir: string,
947
+ appTopos: readonly Topo[] = [],
948
+ scope: WardenScope = EMPTY_WARDEN_SCOPE,
949
+ authoredMcpSurfaceBindingSets:
950
+ | readonly AuthoredMcpSurfaceBindingSet[]
951
+ | undefined = undefined
177
952
  ): ProjectContext => {
178
- const knownTrailIds = new Set<string>();
179
- const detourTargetTrailIds = new Set<string>();
953
+ const context = createMutableProjectContext();
954
+ context.authoredMcpSurfaceBindingSets = authoredMcpSurfaceBindingSets;
955
+ const typeScriptSourceFiles = sourceFiles.filter(
956
+ (sourceFile) => sourceFile.kind === 'typescript'
957
+ );
958
+ const documentationSourceFiles = sourceFiles.filter(
959
+ (sourceFile) => sourceFile.kind === 'documentation'
960
+ );
961
+ context.publicWorkspaces = collectPublicWorkspaces(rootDir, {
962
+ exclude: scope.exclude,
963
+ });
180
964
 
181
- for (const sourceFile of sourceFiles) {
182
- collectKnownTrailIds(
183
- sourceFile.sourceCode,
184
- sourceFile.filePath,
185
- knownTrailIds
186
- );
187
- collectDetourTargetTrailIds(
188
- sourceFile.sourceCode,
189
- sourceFile.filePath,
190
- detourTargetTrailIds
191
- );
965
+ if (appTopos.length > 0) {
966
+ for (const appTopo of appTopos) {
967
+ collectTopoTrailContext(appTopo, context);
968
+ }
969
+ for (const sourceFile of typeScriptSourceFiles) {
970
+ collectFileSupplementalProjectContext(sourceFile, context);
971
+ }
972
+ } else {
973
+ for (const sourceFile of typeScriptSourceFiles) {
974
+ collectFileProjectContext(sourceFile, context);
975
+ }
192
976
  }
193
977
 
194
- return {
195
- detourTargetTrailIds,
196
- knownTrailIds,
197
- };
978
+ for (const sourceFile of typeScriptSourceFiles) {
979
+ collectFileEntityReferences(sourceFile, context);
980
+ }
981
+ collectFileImportResolutions(rootDir, typeScriptSourceFiles, context);
982
+ collectFileDocumentedImportResolutions(
983
+ rootDir,
984
+ documentationSourceFiles,
985
+ context
986
+ );
987
+ collectFileExportedSymbolDefinitions(rootDir, typeScriptSourceFiles, context);
988
+
989
+ return toProjectContext(context);
198
990
  };
199
991
 
200
992
  const isProjectAwareRule = (rule: WardenRule): rule is ProjectAwareWardenRule =>
201
993
  'checkWithContext' in rule;
202
994
 
995
+ const createOptionsDiagnostic = (message: string): WardenDiagnostic => ({
996
+ filePath: '<warden-options>',
997
+ line: 1,
998
+ message,
999
+ rule: 'warden-options',
1000
+ severity: 'error',
1001
+ });
1002
+
1003
+ const emptyProjectWardenRules = (): ProjectWardenRules => ({
1004
+ diagnostics: [],
1005
+ sourceRules: [],
1006
+ topoRules: [],
1007
+ });
1008
+
1009
+ const loadProjectRulesForRun = async (
1010
+ rootDir: string,
1011
+ options: WardenRunOptions,
1012
+ runLint: boolean
1013
+ ): Promise<ProjectWardenRules> => {
1014
+ if (!runLint || options.projectRules === false) {
1015
+ return emptyProjectWardenRules();
1016
+ }
1017
+ return loadProjectWardenRules(rootDir);
1018
+ };
1019
+
1020
+ interface WardenRuleSelector {
1021
+ readonly depth?: WardenDepth | undefined;
1022
+ readonly tier?: WardenRuleTier | undefined;
1023
+ }
1024
+
1025
+ const depthIncludesTier = (
1026
+ depth: WardenDepth,
1027
+ tier: WardenRuleTier
1028
+ ): boolean => {
1029
+ switch (depth) {
1030
+ case 'source': {
1031
+ return tier === 'source-static';
1032
+ }
1033
+ case 'project': {
1034
+ return tier === 'source-static' || tier === 'project-static';
1035
+ }
1036
+ case 'topo': {
1037
+ return (
1038
+ tier === 'source-static' ||
1039
+ tier === 'project-static' ||
1040
+ tier === 'topo-aware'
1041
+ );
1042
+ }
1043
+ case 'all': {
1044
+ return true;
1045
+ }
1046
+ default: {
1047
+ return false;
1048
+ }
1049
+ }
1050
+ };
1051
+
1052
+ const ruleMatchesTier = (
1053
+ metadata: ReturnType<typeof getWardenRuleMetadata>,
1054
+ tier: WardenRuleTier | undefined
1055
+ ): boolean => {
1056
+ if (!tier) {
1057
+ return true;
1058
+ }
1059
+
1060
+ if (!metadata) {
1061
+ return false;
1062
+ }
1063
+
1064
+ return tier === 'advisory'
1065
+ ? metadata.scope === 'advisory'
1066
+ : metadata.tier === tier;
1067
+ };
1068
+
1069
+ const ruleMatchesDepth = (
1070
+ metadata: ReturnType<typeof getWardenRuleMetadata>,
1071
+ depth: WardenDepth | undefined
1072
+ ): boolean => {
1073
+ if (!depth) {
1074
+ return true;
1075
+ }
1076
+
1077
+ if (!metadata) {
1078
+ return false;
1079
+ }
1080
+
1081
+ if (metadata.scope === 'advisory') {
1082
+ return depth === 'all';
1083
+ }
1084
+
1085
+ return depthIncludesTier(depth, metadata.tier);
1086
+ };
1087
+
1088
+ const isSelectedRule = (
1089
+ rule: WardenRule | TopoAwareWardenRule,
1090
+ selector: WardenRuleSelector
1091
+ ): boolean => {
1092
+ const metadata = getWardenRuleMetadata(rule);
1093
+ return selector.tier
1094
+ ? ruleMatchesTier(metadata, selector.tier)
1095
+ : ruleMatchesDepth(metadata, selector.depth);
1096
+ };
1097
+
1098
+ const isSelectedTopoRule = (
1099
+ rule: TopoAwareWardenRule,
1100
+ selector: WardenRuleSelector
1101
+ ): boolean => {
1102
+ const metadata = getWardenRuleMetadata(rule);
1103
+ if (selector.tier) {
1104
+ return metadata
1105
+ ? ruleMatchesTier(metadata, selector.tier)
1106
+ : selector.tier === 'topo-aware';
1107
+ }
1108
+
1109
+ return metadata ? ruleMatchesDepth(metadata, selector.depth) : true;
1110
+ };
1111
+
1112
+ const withDiagnosticGuidance = (
1113
+ diagnostic: WardenDiagnostic
1114
+ ): WardenDiagnostic => {
1115
+ if (diagnostic.guidance !== undefined) {
1116
+ return diagnostic;
1117
+ }
1118
+
1119
+ const guidance = getWardenRuleMetadata(diagnostic.rule)?.guidance;
1120
+ return guidance === undefined ? diagnostic : { ...diagnostic, guidance };
1121
+ };
1122
+
1123
+ const topoRuleFailureDiagnostic = (
1124
+ rule: TopoAwareWardenRule,
1125
+ error: unknown
1126
+ ): WardenDiagnostic => {
1127
+ const cause = error instanceof Error ? error : new Error(String(error));
1128
+ return {
1129
+ filePath: '<topo>',
1130
+ line: 1,
1131
+ message: `Topo-aware rule "${rule.name}" threw: ${cause.message}`,
1132
+ rule: rule.name,
1133
+ severity: 'error',
1134
+ };
1135
+ };
1136
+
203
1137
  /**
204
- * Lint all files against all warden rules.
1138
+ * Run all registered topo-aware rules against the resolved topo.
1139
+ *
1140
+ * Topo-aware rules fire exactly once per run (not per file) because they
1141
+ * inspect the compiled trail graph, not source text.
205
1142
  */
206
- const lintFiles = async (
207
- rootDir: string,
208
- appTopo?: Topo | undefined
209
- ): Promise<WardenDiagnostic[]> => {
210
- const allDiagnostics: WardenDiagnostic[] = [];
211
- const sourceFiles = await loadSourceFiles(rootDir);
212
- const context = appTopo
213
- ? buildProjectContextFromTopo(appTopo)
214
- : buildProjectContextFromFiles(sourceFiles);
1143
+ const lintTopo = async (
1144
+ appTopo: Topo,
1145
+ graph: TopoGraph | undefined,
1146
+ overlays: readonly TopoGraphOverlayRegistration[] | undefined,
1147
+ extraTopoRules: readonly TopoAwareWardenRule[],
1148
+ selector: WardenRuleSelector
1149
+ ): Promise<readonly WardenDiagnostic[]> => {
1150
+ const diagnostics: WardenDiagnostic[] = [];
1151
+ const rules: readonly TopoAwareWardenRule[] = [
1152
+ ...wardenTopoRules.values(),
1153
+ ...extraTopoRules,
1154
+ ].filter((rule) => isSelectedTopoRule(rule, selector));
1155
+ let contextGraph: TopoGraph;
1156
+ try {
1157
+ contextGraph = graph ?? deriveTopoGraph(appTopo, { overlays });
1158
+ } catch (error) {
1159
+ for (const rule of rules) {
1160
+ diagnostics.push(topoRuleFailureDiagnostic(rule, error));
1161
+ }
1162
+ return diagnostics;
1163
+ }
1164
+
1165
+ for (const rule of rules) {
1166
+ try {
1167
+ diagnostics.push(
1168
+ ...(await rule.checkTopo(appTopo, { graph: contextGraph }))
1169
+ );
1170
+ } catch (error) {
1171
+ diagnostics.push(topoRuleFailureDiagnostic(rule, error));
1172
+ }
1173
+ }
1174
+ return diagnostics;
1175
+ };
215
1176
 
1177
+ const lintSourceFiles = (
1178
+ sourceFiles: readonly SourceFile[],
1179
+ context: ProjectContext,
1180
+ extraSourceRules: readonly WardenRule[],
1181
+ selector: WardenRuleSelector
1182
+ ): readonly WardenDiagnostic[] => {
1183
+ const diagnostics: WardenDiagnostic[] = [];
1184
+ const rules = [...wardenRules.values(), ...extraSourceRules];
216
1185
  for (const sourceFile of sourceFiles) {
217
- for (const rule of wardenRules.values()) {
1186
+ for (const rule of rules) {
1187
+ if (
1188
+ sourceFile.kind === 'text' &&
1189
+ rule.name !== 'no-dev-permit-in-source' &&
1190
+ rule.name !== 'public-internal-deep-imports'
1191
+ ) {
1192
+ continue;
1193
+ }
1194
+
1195
+ if (
1196
+ sourceFile.kind === 'documentation' &&
1197
+ rule.name !== 'public-internal-deep-imports'
1198
+ ) {
1199
+ continue;
1200
+ }
1201
+
1202
+ if (!isSelectedRule(rule, selector)) {
1203
+ continue;
1204
+ }
1205
+
218
1206
  if (isProjectAwareRule(rule)) {
219
- allDiagnostics.push(
1207
+ diagnostics.push(
220
1208
  ...rule.checkWithContext(
221
1209
  sourceFile.sourceCode,
222
1210
  sourceFile.filePath,
@@ -225,44 +1213,494 @@ const lintFiles = async (
225
1213
  );
226
1214
  continue;
227
1215
  }
228
-
229
- allDiagnostics.push(
1216
+ diagnostics.push(
230
1217
  ...rule.check(sourceFile.sourceCode, sourceFile.filePath)
231
1218
  );
232
1219
  }
233
1220
  }
1221
+ return diagnostics;
1222
+ };
1223
+
1224
+ const tagTopoDiagnostic = (
1225
+ diagnostic: WardenDiagnostic,
1226
+ topoName: string | undefined
1227
+ ): WardenDiagnostic =>
1228
+ topoName === undefined ? diagnostic : { ...diagnostic, topoName };
1229
+
1230
+ const lintTopoTargets = async (
1231
+ topoTargets: readonly WardenTopoTarget[],
1232
+ extraTopoRules: readonly TopoAwareWardenRule[],
1233
+ selector: WardenRuleSelector,
1234
+ tagDiagnostics: boolean
1235
+ ): Promise<readonly WardenDiagnostic[]> => {
1236
+ const diagnostics: WardenDiagnostic[] = [];
1237
+
1238
+ for (const target of topoTargets) {
1239
+ const topoDiagnostics = await lintTopo(
1240
+ target.topo,
1241
+ target.graph,
1242
+ target.overlays,
1243
+ extraTopoRules,
1244
+ selector
1245
+ );
1246
+ const topoName = target.name ?? target.topo.name;
1247
+ diagnostics.push(
1248
+ ...(tagDiagnostics
1249
+ ? topoDiagnostics.map((diagnostic) =>
1250
+ tagTopoDiagnostic(diagnostic, topoName)
1251
+ )
1252
+ : topoDiagnostics)
1253
+ );
1254
+ }
1255
+
1256
+ return diagnostics;
1257
+ };
1258
+
1259
+ const selectorIncludesTopoRules = (selector: WardenRuleSelector): boolean => {
1260
+ if (selector.tier) {
1261
+ return selector.tier === 'advisory';
1262
+ }
1263
+
1264
+ return !selector.depth || depthIncludesTier(selector.depth, 'topo-aware');
1265
+ };
1266
+
1267
+ /**
1268
+ * Lint all files against all warden rules.
1269
+ */
1270
+ interface WardenLintResult {
1271
+ readonly diagnostics: readonly WardenDiagnostic[];
1272
+ readonly sourceFiles: readonly SourceFile[];
1273
+ }
1274
+
1275
+ const lintFiles = async (
1276
+ rootDir: string,
1277
+ drafts: EffectiveWardenConfig['drafts'],
1278
+ scope: EffectiveWardenConfig['scope'],
1279
+ topoTargets: readonly WardenTopoTarget[],
1280
+ extraTopoRules: readonly TopoAwareWardenRule[],
1281
+ extraSourceRules: readonly WardenRule[],
1282
+ selector: WardenRuleSelector
1283
+ ): Promise<WardenLintResult> => {
1284
+ if (selector.tier === 'topo-aware') {
1285
+ return {
1286
+ diagnostics: [
1287
+ ...(await lintTopoTargets(topoTargets, extraTopoRules, selector, true)),
1288
+ ],
1289
+ sourceFiles: [],
1290
+ };
1291
+ }
1292
+
1293
+ const sourceFiles = filterSourceFilesByScope(
1294
+ filterSourceFilesByDraftMode(await loadSourceFiles(rootDir), drafts),
1295
+ rootDir,
1296
+ scope
1297
+ );
1298
+ const context = buildProjectContext(
1299
+ sourceFiles,
1300
+ rootDir,
1301
+ topoTargets.map((target) => target.topo),
1302
+ scope,
1303
+ collectAuthoredMcpSurfaceBindingSets(topoTargets)
1304
+ );
1305
+ const allDiagnostics: WardenDiagnostic[] = [
1306
+ ...lintSourceFiles(sourceFiles, context, extraSourceRules, selector),
1307
+ ];
1308
+
1309
+ if (
1310
+ topoTargets.length > 0 &&
1311
+ (selector.tier === undefined || selector.tier === 'advisory') &&
1312
+ selectorIncludesTopoRules(selector)
1313
+ ) {
1314
+ allDiagnostics.push(
1315
+ ...(await lintTopoTargets(
1316
+ topoTargets,
1317
+ extraTopoRules,
1318
+ selector,
1319
+ topoTargets.length > 1
1320
+ ))
1321
+ );
1322
+ }
1323
+
1324
+ return { diagnostics: allDiagnostics, sourceFiles };
1325
+ };
1326
+
1327
+ const topoTargetsFromOptions = (
1328
+ options: WardenRunOptions
1329
+ ): readonly WardenTopoTarget[] => {
1330
+ if (options.topos !== undefined && options.topos.length > 0) {
1331
+ return options.topos;
1332
+ }
1333
+
1334
+ return options.topo ? [{ name: options.topo.name, topo: options.topo }] : [];
1335
+ };
1336
+
1337
+ const aggregateDriftHash = (
1338
+ topoTargets: readonly WardenTopoTarget[],
1339
+ driftResults: readonly DriftResult[]
1340
+ ): string => {
1341
+ const currentHashes = new Set(
1342
+ driftResults.map((result) => result.currentHash)
1343
+ );
1344
+ const [onlyHash] = currentHashes;
1345
+ if (currentHashes.size === 1 && onlyHash !== undefined) {
1346
+ return onlyHash;
1347
+ }
1348
+
1349
+ const payload = driftResults
1350
+ .map((result, index) => {
1351
+ const target = topoTargets[index];
1352
+ return {
1353
+ currentHash: result.currentHash,
1354
+ topoName: target?.name ?? target?.topo.name ?? `topo-${String(index)}`,
1355
+ };
1356
+ })
1357
+ .toSorted((left, right) => left.topoName.localeCompare(right.topoName));
1358
+ const hasher = new Bun.CryptoHasher('sha256');
1359
+ hasher.update(JSON.stringify(payload));
1360
+ return hasher.digest('hex');
1361
+ };
1362
+
1363
+ const describeTopoDriftHash = (
1364
+ topoTargets: readonly WardenTopoTarget[],
1365
+ driftResults: readonly DriftResult[]
1366
+ ): string =>
1367
+ driftResults
1368
+ .map((result, index) => {
1369
+ const target = topoTargets[index];
1370
+ const topoName =
1371
+ target?.name ?? target?.topo.name ?? `topo-${String(index)}`;
1372
+ return `${topoName}=${result.committedHash ?? '<none>'}`;
1373
+ })
1374
+ .join(', ');
1375
+
1376
+ const checkDriftForTopoTargets = async (
1377
+ rootDir: string,
1378
+ topoTargets: readonly WardenTopoTarget[]
1379
+ ): Promise<DriftResult> => {
1380
+ if (topoTargets.length <= 1) {
1381
+ const [target] = topoTargets;
1382
+ return checkDrift(
1383
+ rootDir,
1384
+ target?.topo,
1385
+ target === undefined ? undefined : { overlays: target.overlays }
1386
+ );
1387
+ }
1388
+
1389
+ const driftResults = await Promise.all(
1390
+ topoTargets.map((target) =>
1391
+ checkDrift(rootDir, target.topo, { overlays: target.overlays })
1392
+ )
1393
+ );
1394
+ const committedHashes = new Set(
1395
+ driftResults.map((result) => result.committedHash)
1396
+ );
1397
+ if (committedHashes.size > 1) {
1398
+ return {
1399
+ blockedReason: `multi-topo drift expected one committed trails.lock hash but found conflicting hashes: ${describeTopoDriftHash(topoTargets, driftResults)}`,
1400
+ committedHash: null,
1401
+ currentHash: 'blocked',
1402
+ stale: true,
1403
+ };
1404
+ }
1405
+ const committedHash = driftResults[0]?.committedHash ?? null;
1406
+ const blockedReasons = driftResults.flatMap((result, index) => {
1407
+ if (result.blockedReason === undefined) {
1408
+ return [];
1409
+ }
1410
+ const target = topoTargets[index];
1411
+ const topoName =
1412
+ target?.name ?? target?.topo.name ?? `topo-${String(index)}`;
1413
+ return [`${topoName}: ${result.blockedReason}`];
1414
+ });
1415
+
1416
+ if (blockedReasons.length > 0) {
1417
+ return {
1418
+ blockedReason: blockedReasons.join('; '),
1419
+ committedHash,
1420
+ currentHash: 'blocked',
1421
+ stale: true,
1422
+ };
1423
+ }
1424
+
1425
+ const currentHash = aggregateDriftHash(topoTargets, driftResults);
1426
+ const driftedOverlayNamespaces = [
1427
+ ...new Set(
1428
+ driftResults.flatMap((result) => result.driftedOverlayNamespaces ?? [])
1429
+ ),
1430
+ ].toSorted();
1431
+ return {
1432
+ committedHash,
1433
+ currentHash,
1434
+ ...(driftedOverlayNamespaces.length === 0
1435
+ ? {}
1436
+ : { driftedOverlayNamespaces }),
1437
+ stale: committedHash !== null && committedHash !== currentHash,
1438
+ };
1439
+ };
1440
+
1441
+ const shouldRunLint = (options: WardenRunOptions): boolean =>
1442
+ options.tier ? options.tier !== 'drift' : !options.driftOnly;
1443
+
1444
+ const adapterDiagnosticsForRun = (
1445
+ rootDir: string,
1446
+ options: WardenRunOptions
1447
+ ): readonly WardenDiagnostic[] =>
1448
+ options.adapterCheck ? runWardenAdapterChecks(rootDir) : [];
1449
+
1450
+ const shouldRunDrift = (
1451
+ options: WardenRunOptions,
1452
+ effectiveConfig: EffectiveWardenConfig
1453
+ ): boolean => {
1454
+ if (effectiveConfig.lock === 'skip') {
1455
+ return false;
1456
+ }
1457
+
1458
+ if (options.tier) {
1459
+ return options.tier === 'drift';
1460
+ }
1461
+
1462
+ if (options.lintOnly) {
1463
+ return false;
1464
+ }
1465
+
1466
+ return options.driftOnly || effectiveConfig.depth === 'all';
1467
+ };
1468
+
1469
+ const reportPassed = ({
1470
+ drift,
1471
+ errorCount,
1472
+ failOn,
1473
+ warnCount,
1474
+ }: {
1475
+ readonly drift: DriftResult | null;
1476
+ readonly errorCount: number;
1477
+ readonly failOn: WardenFailOn;
1478
+ readonly warnCount: number;
1479
+ }): boolean =>
1480
+ errorCount === 0 &&
1481
+ (failOn === 'error' || warnCount === 0) &&
1482
+ !(drift?.stale ?? false) &&
1483
+ drift?.blockedReason === undefined;
1484
+
1485
+ const buildCliConfigLayer = (options: WardenRunOptions): WardenConfigLayer => ({
1486
+ ...(options.apps ? { apps: [...options.apps] } : {}),
1487
+ ...(options.depth ? { depth: options.depth } : {}),
1488
+ ...(options.drafts ? { drafts: options.drafts } : {}),
1489
+ ...(options.failOn ? { failOn: options.failOn } : {}),
1490
+ ...(options.format ? { format: options.format } : {}),
1491
+ ...(options.scope ? { scope: options.scope } : {}),
1492
+ ...(options.lock ? { lock: options.lock } : {}),
1493
+ ...(options.noLockMutation === undefined
1494
+ ? {}
1495
+ : { noLockMutation: options.noLockMutation }),
1496
+ });
1497
+
1498
+ const fixSummary = (application: WardenFixApplication): WardenFixSummary => ({
1499
+ applied: application.applied,
1500
+ filesChanged: application.filesChanged,
1501
+ skipped: application.skipped,
1502
+ });
1503
+
1504
+ const filterAppliedFixDiagnostics = (
1505
+ diagnostics: readonly WardenDiagnostic[],
1506
+ appliedDiagnostics: readonly WardenDiagnostic[]
1507
+ ): readonly WardenDiagnostic[] => {
1508
+ if (appliedDiagnostics.length === 0) {
1509
+ return diagnostics;
1510
+ }
1511
+ const applied = new Set(appliedDiagnostics);
1512
+ return diagnostics.filter((diagnostic) => !applied.has(diagnostic));
1513
+ };
1514
+
1515
+ const blockedDriftAfterSourceFixes = (): DriftResult => ({
1516
+ blockedReason:
1517
+ 'Source fixes were applied; rerun Warden to refresh drift evidence.',
1518
+ committedHash: null,
1519
+ currentHash: 'blocked',
1520
+ stale: true,
1521
+ });
1522
+
1523
+ /**
1524
+ * Apply every safe source fix among a run's diagnostics, writing patched files.
1525
+ *
1526
+ * Diagnostics with a safe, edit-bearing fix are grouped by file; each file is
1527
+ * re-read so the rule's recorded offsets stay valid, patched via
1528
+ * {@link applySafeFixesToSource}, and written back only when its source
1529
+ * actually changed. Diagnostics that carry fix metadata but are not safe with
1530
+ * edits (review-required or edit-less) are counted as skipped and left reported
1531
+ * for a human or downstream regrade to resolve. Diagnostics without fix
1532
+ * metadata — including topo diagnostics, which carry no source span — are
1533
+ * neither applied nor counted in `skipped`.
1534
+ */
1535
+ export const applySafeFixesToFiles = async (
1536
+ diagnostics: readonly WardenDiagnostic[],
1537
+ options: {
1538
+ readonly allowedFilePaths?: ReadonlySet<string> | readonly string[];
1539
+ readonly rootDir: string;
1540
+ }
1541
+ ): Promise<WardenFixApplication> => {
1542
+ const rootDir = resolve(options.rootDir);
1543
+ const allowedFilePaths =
1544
+ options.allowedFilePaths === undefined
1545
+ ? undefined
1546
+ : new Set(
1547
+ [...options.allowedFilePaths].map((filePath) => resolve(filePath))
1548
+ );
1549
+ const fixableByFile = new Map<string, WardenDiagnostic[]>();
1550
+ let skipped = 0;
1551
+ for (const diagnostic of diagnostics) {
1552
+ if (diagnostic.fix === undefined) {
1553
+ continue;
1554
+ }
1555
+ if (hasSafeFixEdits(diagnostic)) {
1556
+ const filePath = resolve(diagnostic.filePath);
1557
+ const rootRelativePath = relative(rootDir, filePath);
1558
+ const insideRoot =
1559
+ rootRelativePath.length === 0 ||
1560
+ (!rootRelativePath.startsWith('..') && !isAbsolute(rootRelativePath));
1561
+ if (
1562
+ !insideRoot ||
1563
+ (allowedFilePaths !== undefined && !allowedFilePaths.has(filePath))
1564
+ ) {
1565
+ skipped += 1;
1566
+ continue;
1567
+ }
1568
+ const bucket = fixableByFile.get(filePath) ?? [];
1569
+ bucket.push(diagnostic);
1570
+ fixableByFile.set(filePath, bucket);
1571
+ } else {
1572
+ skipped += 1;
1573
+ }
1574
+ }
234
1575
 
235
- return allDiagnostics;
1576
+ let applied = 0;
1577
+ const appliedDiagnostics: WardenDiagnostic[] = [];
1578
+ let filesChanged = 0;
1579
+ for (const [filePath, group] of fixableByFile) {
1580
+ const source = await Bun.file(filePath).text();
1581
+ const result = applySafeFixesToSource(source, group);
1582
+ applied += result.applied.length;
1583
+ appliedDiagnostics.push(...result.applied);
1584
+ if (result.changed) {
1585
+ await Bun.write(filePath, result.patched);
1586
+ filesChanged += 1;
1587
+ }
1588
+ }
1589
+
1590
+ return { applied, appliedDiagnostics, filesChanged, skipped };
236
1591
  };
237
1592
 
238
1593
  /**
239
1594
  * Run all warden checks and return a structured report.
240
1595
  */
241
1596
  export const runWarden = async (
242
- options: WardenOptions = {}
1597
+ options: WardenRunOptions = {}
243
1598
  ): Promise<WardenReport> => {
244
- const rootDir = resolve(options.rootDir ?? process.cwd());
245
- const allDiagnostics = options.driftOnly
246
- ? []
247
- : await lintFiles(rootDir, options.topo);
248
- const drift = options.lintOnly
249
- ? null
250
- : await checkDrift(rootDir, options.topo);
251
-
252
- const errorCount = allDiagnostics.filter(
1599
+ const { rootDir } = resolveTrailsProjectRoot({
1600
+ explicitRootDir: options.rootDir,
1601
+ startDir: process.cwd(),
1602
+ });
1603
+ const { diagnostics: configDiagnostics, effectiveConfig } =
1604
+ resolveWardenConfig({
1605
+ cli: buildCliConfigLayer(options),
1606
+ config: options.config,
1607
+ env: options.env,
1608
+ });
1609
+ const optionDiagnostics =
1610
+ !options.tier && options.lintOnly && options.driftOnly
1611
+ ? [
1612
+ createOptionsDiagnostic(
1613
+ 'lintOnly and driftOnly cannot both be true. Use tier to select a single Warden mode.'
1614
+ ),
1615
+ ]
1616
+ : [];
1617
+ const topoTargets = topoTargetsFromOptions(options);
1618
+ const selector = {
1619
+ depth: options.tier ? undefined : effectiveConfig.depth,
1620
+ tier: options.tier,
1621
+ } satisfies WardenRuleSelector;
1622
+ const runLint = shouldRunLint(options);
1623
+ const runDrift = shouldRunDrift(options, effectiveConfig);
1624
+ const projectRules = await loadProjectRulesForRun(rootDir, options, runLint);
1625
+ const lintResult = runLint
1626
+ ? await lintFiles(
1627
+ rootDir,
1628
+ effectiveConfig.drafts,
1629
+ effectiveConfig.scope,
1630
+ topoTargets,
1631
+ [...projectRules.topoRules, ...(options.extraTopoRules ?? [])],
1632
+ [...projectRules.sourceRules, ...(options.extraSourceRules ?? [])],
1633
+ selector
1634
+ )
1635
+ : { diagnostics: [], sourceFiles: [] };
1636
+ const adapterDiagnostics = adapterDiagnosticsForRun(rootDir, options);
1637
+
1638
+ const rawDiagnostics = [
1639
+ ...configDiagnostics,
1640
+ ...projectRules.diagnostics,
1641
+ ...optionDiagnostics,
1642
+ ...lintResult.diagnostics,
1643
+ ...adapterDiagnostics,
1644
+ ];
1645
+ const allDiagnostics = rawDiagnostics.map(withDiagnosticGuidance);
1646
+ const fixApplication = options.fix
1647
+ ? await applySafeFixesToFiles(allDiagnostics, {
1648
+ allowedFilePaths: lintResult.sourceFiles.map(
1649
+ (sourceFile) => sourceFile.filePath
1650
+ ),
1651
+ rootDir,
1652
+ })
1653
+ : undefined;
1654
+ const reportDiagnostics = filterAppliedFixDiagnostics(
1655
+ allDiagnostics,
1656
+ fixApplication?.appliedDiagnostics ?? []
1657
+ );
1658
+ let drift: DriftResult | null = null;
1659
+ if (runDrift) {
1660
+ drift =
1661
+ fixApplication !== undefined && fixApplication.filesChanged > 0
1662
+ ? blockedDriftAfterSourceFixes()
1663
+ : await checkDriftForTopoTargets(rootDir, topoTargets);
1664
+ }
1665
+
1666
+ const errorCount = reportDiagnostics.filter(
253
1667
  (d) => d.severity === 'error'
254
1668
  ).length;
255
- const warnCount = allDiagnostics.filter((d) => d.severity === 'warn').length;
1669
+ const warnCount = reportDiagnostics.filter(
1670
+ (d) => d.severity === 'warn'
1671
+ ).length;
1672
+ const topoNames =
1673
+ topoTargets.length > 0
1674
+ ? topoTargets.map((target) => target.name ?? target.topo.name)
1675
+ : undefined;
256
1676
 
257
1677
  return {
258
- diagnostics: allDiagnostics,
1678
+ diagnostics: reportDiagnostics,
259
1679
  drift,
1680
+ effectiveConfig,
260
1681
  errorCount,
261
- passed: errorCount === 0 && !(drift?.stale ?? false),
1682
+ ...(fixApplication === undefined
1683
+ ? {}
1684
+ : { fixes: fixSummary(fixApplication) }),
1685
+ passed: reportPassed({
1686
+ drift,
1687
+ errorCount,
1688
+ failOn: effectiveConfig.failOn,
1689
+ warnCount,
1690
+ }),
1691
+ ...(topoNames === undefined ? {} : { topoNames }),
262
1692
  warnCount,
263
1693
  };
264
1694
  };
265
1695
 
1696
+ const formatPlainGuidanceLink = (link: WardenGuidanceLink): string => {
1697
+ const target = link.path ?? link.url;
1698
+ if (target === undefined || target === link.label) {
1699
+ return link.label;
1700
+ }
1701
+ return `${link.label} (${target})`;
1702
+ };
1703
+
266
1704
  /**
267
1705
  * Format the lint section of the report.
268
1706
  */
@@ -280,6 +1718,25 @@ const formatLintSection = (report: WardenReport): string[] => {
280
1718
  lines.push(
281
1719
  ` ${d.filePath}:${String(d.line)} [${prefix}] ${d.rule} ${d.message}`
282
1720
  );
1721
+ if (d.guidance !== undefined) {
1722
+ lines.push(` Next: ${d.guidance.summary}`);
1723
+ for (const [index, step] of (d.guidance.steps ?? []).entries()) {
1724
+ lines.push(` ${String(index + 1)}. ${step}`);
1725
+ }
1726
+ if (d.guidance.commands !== undefined) {
1727
+ lines.push(
1728
+ ` Commands: ${d.guidance.commands.map((cmd) => `\`${cmd}\``).join(', ')}`
1729
+ );
1730
+ }
1731
+ if (d.guidance.docs !== undefined) {
1732
+ lines.push(
1733
+ ` Docs: ${d.guidance.docs.map(formatPlainGuidanceLink).join(', ')}`
1734
+ );
1735
+ }
1736
+ if (d.guidance.relatedRules !== undefined) {
1737
+ lines.push(` Related: ${d.guidance.relatedRules.join(', ')}`);
1738
+ }
1739
+ }
283
1740
  }
284
1741
 
285
1742
  return lines;
@@ -292,8 +1749,11 @@ const formatDriftSection = (drift: DriftResult | null): string[] => {
292
1749
  if (drift === null) {
293
1750
  return [];
294
1751
  }
1752
+ if (drift.blockedReason !== undefined) {
1753
+ return [`Drift: blocked (${drift.blockedReason})`, ''];
1754
+ }
295
1755
  const label = drift.stale
296
- ? 'Drift: surface.lock is stale (regenerate with `trails survey generate`)'
1756
+ ? `Drift: ${staleDriftMessage(drift)}`
297
1757
  : 'Drift: clean';
298
1758
  return [label, ''];
299
1759
  };
@@ -309,7 +1769,12 @@ const formatResultLine = (report: WardenReport): string => {
309
1769
  if (report.errorCount > 0) {
310
1770
  parts.push(`${report.errorCount} errors`);
311
1771
  }
312
- if (report.drift?.stale) {
1772
+ if (report.warnCount > 0 && report.effectiveConfig?.failOn === 'warning') {
1773
+ parts.push(`${report.warnCount} warnings`);
1774
+ }
1775
+ if (report.drift?.blockedReason !== undefined) {
1776
+ parts.push('established exports blocked');
1777
+ } else if (report.drift?.stale) {
313
1778
  parts.push('drift detected');
314
1779
  }
315
1780
  return `Result: FAIL (${parts.join(', ')})`;