@ontrails/warden 1.0.0-beta.3 → 1.0.0-beta.32

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