@ontrails/warden 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/CHANGELOG.md +1556 -0
  2. package/README.md +200 -0
  3. package/bin/warden.ts +51 -0
  4. package/package.json +51 -0
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +2159 -0
  7. package/src/command.ts +1167 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +260 -0
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +178 -0
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +295 -0
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/regrade-history.ts +188 -0
  18. package/src/regrade-receipt.ts +484 -0
  19. package/src/resolve.ts +531 -0
  20. package/src/rules/activation-orphan.ts +97 -0
  21. package/src/rules/captured-kernel.ts +375 -0
  22. package/src/rules/circular-refs.ts +150 -0
  23. package/src/rules/cli-command-route-coherence.ts +177 -0
  24. package/src/rules/composes-declarations.ts +839 -0
  25. package/src/rules/context-no-surface-types.ts +214 -0
  26. package/src/rules/dead-internal-trail.ts +161 -0
  27. package/src/rules/dead-public-trail.ts +258 -0
  28. package/src/rules/draft-file-marking.ts +155 -0
  29. package/src/rules/draft-visible-debt.ts +83 -0
  30. package/src/rules/duplicate-exported-symbol.ts +172 -0
  31. package/src/rules/duplicate-public-contract.ts +137 -0
  32. package/src/rules/entity-exists.ts +254 -0
  33. package/src/rules/entity-ids.ts +15 -0
  34. package/src/rules/error-mapping-completeness.ts +290 -0
  35. package/src/rules/example-valid.ts +395 -0
  36. package/src/rules/fires-declarations.ts +740 -0
  37. package/src/rules/governed-symbol-residue.ts +535 -0
  38. package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
  39. package/src/rules/implementation-returns-result.ts +1733 -0
  40. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  41. package/src/rules/incomplete-crud.ts +583 -0
  42. package/src/rules/index.ts +328 -0
  43. package/src/rules/intent-propagation.ts +125 -0
  44. package/src/rules/layer-field-name-drift.ts +102 -0
  45. package/src/rules/library-render-coherence.ts +97 -0
  46. package/src/rules/metadata.ts +929 -0
  47. package/src/rules/missing-reconcile.ts +97 -0
  48. package/src/rules/missing-visibility.ts +111 -0
  49. package/src/rules/no-destructured-compose.ts +196 -0
  50. package/src/rules/no-dev-permit-in-source.ts +99 -0
  51. package/src/rules/no-direct-implementation-call.ts +52 -0
  52. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  53. package/src/rules/no-legacy-layer-imports.ts +211 -0
  54. package/src/rules/no-native-error-result.ts +118 -0
  55. package/src/rules/no-redundant-result-error-wrap.ts +382 -0
  56. package/src/rules/no-retired-cross-vocabulary.ts +203 -0
  57. package/src/rules/no-sync-result-assumption.ts +1199 -0
  58. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  59. package/src/rules/no-throw-in-implementation.ts +48 -0
  60. package/src/rules/no-top-level-surface.ts +371 -0
  61. package/src/rules/on-references-exist.ts +194 -0
  62. package/src/rules/orphaned-signal.ts +149 -0
  63. package/src/rules/owner-render-parity.ts +143 -0
  64. package/src/rules/permit-governance.ts +25 -0
  65. package/src/rules/prefer-schema-inference.ts +141 -0
  66. package/src/rules/public-export-example-coverage.ts +561 -0
  67. package/src/rules/public-internal-deep-imports.ts +454 -0
  68. package/src/rules/public-output-schema.ts +29 -0
  69. package/src/rules/public-union-output-discriminants.ts +150 -0
  70. package/src/rules/read-intent-fires.ts +188 -0
  71. package/src/rules/reference-exists.ts +97 -0
  72. package/src/rules/registry-names.ts +171 -0
  73. package/src/rules/resolved-import-boundary.ts +146 -0
  74. package/src/rules/resource-declarations.ts +697 -0
  75. package/src/rules/resource-exists.ts +181 -0
  76. package/src/rules/resource-id-grammar.ts +65 -0
  77. package/src/rules/resource-mock-coverage.ts +115 -0
  78. package/src/rules/retired-vocabulary.ts +1407 -0
  79. package/src/rules/scan.ts +59 -0
  80. package/src/rules/scheduled-destroy-intent.ts +44 -0
  81. package/src/rules/signal-graph-coaching.ts +220 -0
  82. package/src/rules/source/composition.ts +165 -0
  83. package/src/rules/source/drafts.ts +164 -0
  84. package/src/rules/source/entities.ts +618 -0
  85. package/src/rules/source/pragmas.ts +45 -0
  86. package/src/rules/source/resources.ts +64 -0
  87. package/src/rules/source/signals.ts +397 -0
  88. package/src/rules/source/stores.ts +310 -0
  89. package/src/rules/specs.ts +388 -0
  90. package/src/rules/static-resource-accessor-preference.ts +654 -0
  91. package/src/rules/structure.ts +234 -0
  92. package/src/rules/surface-overlay-coherence.ts +262 -0
  93. package/src/rules/surface-trailhead-coherence.ts +366 -0
  94. package/src/rules/trail-fork-coaching.ts +625 -0
  95. package/src/rules/trail-versioning-source.ts +1076 -0
  96. package/src/rules/trail-versioning-topo.ts +172 -0
  97. package/src/rules/trailhead-override-divergence.ts +356 -0
  98. package/src/rules/types.ts +464 -0
  99. package/src/rules/unmaterialized-activation-source.ts +85 -0
  100. package/src/rules/unreachable-detour-shadowing.ts +343 -0
  101. package/src/rules/valid-describe-refs.ts +224 -0
  102. package/src/rules/valid-detour-contract.ts +78 -0
  103. package/src/rules/warden-export-symmetry.ts +540 -0
  104. package/src/rules/warden-rules-use-ast.ts +1109 -0
  105. package/src/rules/webhook-route-collision.ts +306 -0
  106. package/src/rules/workspace-lock-ownership.ts +34 -0
  107. package/src/trails/activation-orphan.trail.ts +84 -0
  108. package/src/trails/captured-kernel.trail.ts +108 -0
  109. package/src/trails/circular-refs.trail.ts +29 -0
  110. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  111. package/src/trails/composes-declarations.trail.ts +22 -0
  112. package/src/trails/context-no-surface-types.trail.ts +21 -0
  113. package/src/trails/dead-internal-trail.trail.ts +26 -0
  114. package/src/trails/dead-public-trail.trail.ts +31 -0
  115. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  116. package/src/trails/draft-file-marking.trail.ts +16 -0
  117. package/src/trails/draft-visible-debt.trail.ts +16 -0
  118. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  119. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  120. package/src/trails/entity-exists.trail.ts +21 -0
  121. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  122. package/src/trails/example-valid.trail.ts +25 -0
  123. package/src/trails/fires-declarations.trail.ts +23 -0
  124. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  125. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  126. package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
  127. package/src/trails/implementation-returns-result.trail.ts +20 -0
  128. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  129. package/src/trails/incomplete-crud.trail.ts +39 -0
  130. package/src/trails/index.ts +91 -0
  131. package/src/trails/intent-propagation.trail.ts +30 -0
  132. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  133. package/src/trails/library-render-coherence.trail.ts +43 -0
  134. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  135. package/src/trails/missing-reconcile.trail.ts +33 -0
  136. package/src/trails/missing-visibility.trail.ts +22 -0
  137. package/src/trails/no-destructured-compose.trail.ts +44 -0
  138. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  139. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  140. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  141. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  142. package/src/trails/no-native-error-result.trail.ts +18 -0
  143. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  144. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  145. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  146. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  147. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  148. package/src/trails/no-top-level-surface.trail.ts +43 -0
  149. package/src/trails/on-references-exist.trail.ts +21 -0
  150. package/src/trails/orphaned-signal.trail.ts +36 -0
  151. package/src/trails/owner-render-parity.trail.ts +26 -0
  152. package/src/trails/pending-force.trail.ts +21 -0
  153. package/src/trails/permit-governance.trail.ts +51 -0
  154. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  155. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  156. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  157. package/src/trails/public-output-schema.trail.ts +55 -0
  158. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  159. package/src/trails/read-intent-fires.trail.ts +20 -0
  160. package/src/trails/reference-exists.trail.ts +25 -0
  161. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  162. package/src/trails/resource-declarations.trail.ts +25 -0
  163. package/src/trails/resource-exists.trail.ts +27 -0
  164. package/src/trails/resource-id-grammar.trail.ts +39 -0
  165. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  166. package/src/trails/run.ts +205 -0
  167. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  168. package/src/trails/schema.ts +295 -0
  169. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  170. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  171. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  172. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  173. package/src/trails/topo.ts +6 -0
  174. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  175. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  176. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  177. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  178. package/src/trails/valid-describe-refs.trail.ts +18 -0
  179. package/src/trails/valid-detour-contract.trail.ts +71 -0
  180. package/src/trails/version-gap.trail.ts +35 -0
  181. package/src/trails/version-pinned-compose.trail.ts +23 -0
  182. package/src/trails/version-without-examples.trail.ts +38 -0
  183. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  184. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  185. package/src/trails/webhook-route-collision.trail.ts +50 -0
  186. package/src/trails/workspace-lock-ownership.trail.ts +16 -0
  187. package/src/trails/wrap-rule.ts +269 -0
  188. package/src/workspaces.ts +264 -0
package/src/cli.ts ADDED
@@ -0,0 +1,2159 @@
1
+ /**
2
+ * Warden CLI command runner.
3
+ *
4
+ * Scans TypeScript files, runs all warden rules, optionally checks drift,
5
+ * and returns a structured report.
6
+ */
7
+
8
+ import { isAbsolute, relative, resolve } from 'node:path';
9
+
10
+ import {
11
+ readTrailsProjectIdentity,
12
+ resolveTrailsProjectRoot,
13
+ } from '@ontrails/config';
14
+ import {
15
+ getEntityReferences,
16
+ matchesAnyPathGlob,
17
+ resolveSurfaceOverlayBindings,
18
+ surfaceBindingsFromLockOverlays,
19
+ } from '@ontrails/core';
20
+ import type { SurfaceBindings, Topo } from '@ontrails/core';
21
+ import { deriveTopoGraph, deriveWorkspaceView } from '@ontrails/topography';
22
+ import type {
23
+ TopoGraph,
24
+ TopoGraphOverlayRegistration,
25
+ UnownedWorkspaceLockObservation,
26
+ } from '@ontrails/topography';
27
+
28
+ import type {
29
+ EffectiveWardenConfig,
30
+ WardenConfigInput,
31
+ WardenConfigLayer,
32
+ WardenDepth,
33
+ WardenFailOn,
34
+ WardenFormat,
35
+ WardenScope,
36
+ WardenLockMode,
37
+ } from './config.js';
38
+ import { runWardenAdapterChecks } from './adapter-check.js';
39
+ import { resolveWardenConfig } from './config.js';
40
+ import { isDraftMarkedFile } from './draft.js';
41
+ import { applySafeFixesToSource, hasSafeFixEdits } from './fix.js';
42
+ import type { DriftResult } from './drift.js';
43
+ import { checkDrift, staleDriftMessage } from './drift.js';
44
+ import { loadProjectWardenRules } from './project-rules.js';
45
+ import type { ProjectWardenRules } from './project-rules.js';
46
+ import { loadGovernedVocabularyHistory } from './regrade-history.js';
47
+ import {
48
+ collectProjectDocumentationImportResolutions,
49
+ collectProjectExportedSymbolDefinitions,
50
+ collectProjectImportResolutions,
51
+ collectPublicWorkspaces,
52
+ } from './project-context.js';
53
+ import {
54
+ collectComposeTargetTrailIds,
55
+ collectTrailIntentsById,
56
+ } from './rules/source/composition.js';
57
+ import {
58
+ collectEntityDefinitionIds,
59
+ collectEntityReferenceTargetsByName,
60
+ } from './rules/source/entities.js';
61
+ import { collectResourceDefinitionIds } from './rules/source/resources.js';
62
+ import {
63
+ collectOnTargetSignalIds as collectOnTargetSignalIdsFromAst,
64
+ collectSignalDefinitionIds,
65
+ } from './rules/source/signals.js';
66
+ import {
67
+ collectCrudTableIds as collectCrudTableIdsFromAst,
68
+ collectReconcileTableIds as collectReconcileTableIdsFromAst,
69
+ } from './rules/source/stores.js';
70
+ import {
71
+ collectSourceTree,
72
+ findTrailDefinitions,
73
+ parse,
74
+ } from '@ontrails/source';
75
+ import type { SourceTreeCollection } from '@ontrails/source';
76
+ import { collectFileCrudCoverage } from './rules/incomplete-crud.js';
77
+ import { wardenRules, wardenTopoRules } from './rules/index.js';
78
+ import { getWardenRuleMetadata } from './rules/metadata.js';
79
+ import {
80
+ isWardenDevPermitTestScanTarget,
81
+ isWardenSourceScanTarget,
82
+ } from './rules/scan.js';
83
+ import type {
84
+ AuthoredMcpSurfaceBindingSet,
85
+ ProjectAwareWardenRule,
86
+ ProjectContext,
87
+ TopoAwareWardenRule,
88
+ WardenDiagnostic,
89
+ WardenExportedSymbolDefinition,
90
+ WardenGuidanceLink,
91
+ WardenRule,
92
+ WardenRuleTier,
93
+ } from './rules/types.js';
94
+ import type { WardenImportResolution } from './resolve.js';
95
+
96
+ /**
97
+ * Resolved topo input for Warden runs that govern multiple apps.
98
+ */
99
+ export interface WardenTopoTarget {
100
+ /** Optional precomputed topo graph, including graph-only audit annotations. */
101
+ readonly graph?: TopoGraph | undefined;
102
+ /** Stable app/topo label used to tag topo-aware diagnostics. */
103
+ readonly name?: string | undefined;
104
+ /**
105
+ * App-module overlay registrations collected through the shared
106
+ * `resolveTrailsOverlays` channel, so fresh drift and topo-aware rule
107
+ * derivations carry the same overlays the committed lock embeds.
108
+ */
109
+ readonly overlays?: readonly TopoGraphOverlayRegistration[] | undefined;
110
+ /** Whether this configured app must have committed lock evidence. */
111
+ readonly requireCommittedLock?: boolean | undefined;
112
+ /** App-local root used only when reading that target's committed lock. */
113
+ readonly rootDir?: string | undefined;
114
+ /** Resolved topo module to inspect. */
115
+ readonly topo: Topo;
116
+ }
117
+
118
+ /**
119
+ * Options for the shared Warden runner.
120
+ */
121
+ export interface WardenRunOptions {
122
+ /** Root directory to scan for TypeScript files. Defaults to cwd. */
123
+ readonly rootDir?: string | undefined;
124
+ /** Warden config section from `trails.config.ts`, if already loaded. */
125
+ readonly config?: WardenConfigInput | undefined;
126
+ /** Resolved selected Config path, when already known by the caller. */
127
+ readonly configPath?: string | undefined;
128
+ /** CLI/config-layer app names carried through shared resolution. */
129
+ readonly apps?: readonly string[] | undefined;
130
+ /** Include shared adapter authoring checks as Warden diagnostics. */
131
+ readonly adapterCheck?: boolean | undefined;
132
+ /** Cumulative analysis depth for the final M1 surfaces. */
133
+ readonly depth?: WardenDepth | undefined;
134
+ /** Draft-state handling mode for final M1 surfaces. */
135
+ readonly drafts?: EffectiveWardenConfig['drafts'] | undefined;
136
+ /** Failure threshold used to compute `report.passed`. */
137
+ readonly failOn?: WardenFailOn | undefined;
138
+ /**
139
+ * Apply safe source fixes among the run's diagnostics, writing changed files.
140
+ *
141
+ * Only `safety: 'safe'` fixes with concrete edits are applied; review-required,
142
+ * edit-less, and topo diagnostics stay reported but unapplied.
143
+ */
144
+ readonly fix?: boolean | undefined;
145
+ /** Output format requested by the caller. */
146
+ readonly format?: WardenFormat | undefined;
147
+ /** Root-relative source paths that Warden should not govern. */
148
+ readonly scope?: WardenScope | undefined;
149
+ /** Lockfile mode requested by the caller. */
150
+ readonly lock?: WardenLockMode | undefined;
151
+ /** Suppress lockfile mutation for CI/pre-push callers. */
152
+ readonly noLockMutation?: boolean | undefined;
153
+ /** Environment layer for config resolution. Pass `process.env` at process boundaries. */
154
+ readonly env?: Record<string, string | undefined> | undefined;
155
+ /** Only run lint rules, skip drift detection */
156
+ readonly lintOnly?: boolean | undefined;
157
+ /** Only run drift detection, skip lint rules */
158
+ readonly driftOnly?: boolean | undefined;
159
+ /**
160
+ * Run a single Warden tier. Defaults to all lint tiers plus drift.
161
+ *
162
+ * Selecting a non-drift tier skips drift detection; selecting `drift` skips
163
+ * lint rule dispatch. `lintOnly` and `driftOnly` remain compatibility shims.
164
+ */
165
+ readonly tier?: WardenRuleTier | undefined;
166
+ /**
167
+ * Load project-local Warden rules from `.trails/rules.ts` or `.trails/rules/`.
168
+ *
169
+ * Enabled by default for normal runs so a Trails app can carry migration or
170
+ * repo-local governance with the project instead of shipping it from
171
+ * `@ontrails/warden`. Set to `false` for narrow tests or embedders that need
172
+ * only built-in and explicitly provided rules.
173
+ */
174
+ readonly projectRules?: boolean | undefined;
175
+ /**
176
+ * App topology for drift detection. When provided, enables real topology
177
+ * drift comparison and unlocks the topo-aware rule dispatch path.
178
+ *
179
+ * @remarks
180
+ * Topo-aware rules (both built-in `wardenTopoRules` and `extraTopoRules`)
181
+ * only fire when a `Topo` is supplied. Runs without a topo silently skip
182
+ * topo-aware dispatch — callers that depend on a topo-aware rule firing
183
+ * must pass `topo` explicitly.
184
+ */
185
+ readonly topo?: Topo | undefined;
186
+ /**
187
+ * Multiple resolved topos to govern in one invocation.
188
+ *
189
+ * Source/project rules run once; topo-aware rules run once per target.
190
+ */
191
+ readonly topos?: readonly WardenTopoTarget[] | undefined;
192
+ /**
193
+ * Extra topo-aware rules to run in addition to the built-in registry.
194
+ *
195
+ * Primarily a test hook — production callers should register rules via
196
+ * `wardenTopoRules` in `rules/index.ts`. These rules are only invoked
197
+ * when `topo` is also supplied (see `topo` remarks).
198
+ */
199
+ readonly extraTopoRules?: readonly TopoAwareWardenRule[] | undefined;
200
+ /**
201
+ * Extra source rules to run in addition to the built-in registry.
202
+ *
203
+ * Primarily a test hook — production callers should register durable rules
204
+ * via `wardenRules` in `rules/index.ts`.
205
+ */
206
+ readonly extraSourceRules?: readonly WardenRule[] | undefined;
207
+ }
208
+
209
+ /** Backwards-compatible name for older consumers. */
210
+ export type WardenOptions = WardenRunOptions;
211
+
212
+ /**
213
+ * Aggregate outcome of a `--fix` pass over a run's diagnostics.
214
+ */
215
+ export interface WardenFixSummary {
216
+ /** Diagnostics whose safe fix was applied to source. */
217
+ readonly applied: number;
218
+ /** Source files rewritten with patched content. */
219
+ readonly filesChanged: number;
220
+ /** Diagnostics carrying fix metadata left unapplied (review or edit-less). */
221
+ readonly skipped: number;
222
+ }
223
+
224
+ export interface WardenFixApplication extends WardenFixSummary {
225
+ /** Diagnostics removed from the final report because their safe fix applied. */
226
+ readonly appliedDiagnostics: readonly WardenDiagnostic[];
227
+ }
228
+
229
+ /** Drift evidence for one named topo target before workspace aggregation. */
230
+ export interface WardenTopoDriftResult {
231
+ readonly drift: DriftResult;
232
+ readonly name: string;
233
+ readonly rootDir: string;
234
+ }
235
+
236
+ /**
237
+ * Result of a warden run.
238
+ */
239
+ export interface WardenReport {
240
+ /** All diagnostics from lint rules */
241
+ readonly diagnostics: readonly WardenDiagnostic[];
242
+ /** Count of error-severity diagnostics */
243
+ readonly errorCount: number;
244
+ /** Count of warn-severity diagnostics */
245
+ readonly warnCount: number;
246
+ /** Drift detection result, or null if skipped */
247
+ readonly drift: DriftResult | null;
248
+ /** Whether the warden run passed (no errors, no drift) */
249
+ readonly passed: boolean;
250
+ /** Effective shared config consumed by this run. */
251
+ readonly effectiveConfig?: EffectiveWardenConfig | undefined;
252
+ /** Resolved topo/app labels governed by this run. */
253
+ readonly topoNames?: readonly string[] | undefined;
254
+ /** Per-target drift evidence retained before workspace aggregation. */
255
+ readonly topoDrift?: readonly WardenTopoDriftResult[] | undefined;
256
+ /** Safe-fix application summary, present only when a `--fix` pass ran. */
257
+ readonly fixes?: WardenFixSummary | undefined;
258
+ }
259
+
260
+ /**
261
+ * Collect Warden scan targets under a directory, excluding generated and test
262
+ * surfaces that should not contribute most committed-source diagnostics.
263
+ */
264
+ const collectFilesMatching = (
265
+ collection: NonNullable<ReturnType<typeof collectSourceTree>>,
266
+ pattern: string,
267
+ dot = false
268
+ ): readonly string[] => {
269
+ const glob = new Bun.Glob(pattern);
270
+ const files: string[] = [];
271
+ for (const file of collection.files) {
272
+ if (
273
+ (dot ||
274
+ !file.path.split('/').some((segment) => segment.startsWith('.'))) &&
275
+ glob.match(file.path) &&
276
+ isWardenSourceScanTarget(file.path)
277
+ ) {
278
+ files.push(file.absolutePath);
279
+ }
280
+ }
281
+ return files;
282
+ };
283
+
284
+ const collectTsFiles = (
285
+ collection: NonNullable<ReturnType<typeof collectSourceTree>>
286
+ ): readonly string[] => collectFilesMatching(collection, '**/*.ts');
287
+
288
+ const draftModeIncludesFile = (
289
+ filePath: string,
290
+ drafts: EffectiveWardenConfig['drafts']
291
+ ): boolean => {
292
+ const isDraftFile = isDraftMarkedFile(filePath);
293
+ if (drafts === 'exclude') {
294
+ return !isDraftFile;
295
+ }
296
+ if (drafts === 'only') {
297
+ return isDraftFile;
298
+ }
299
+ return true;
300
+ };
301
+
302
+ const filterSourceFilesByDraftMode = (
303
+ sourceFiles: readonly SourceFile[],
304
+ drafts: EffectiveWardenConfig['drafts']
305
+ ): readonly SourceFile[] =>
306
+ drafts === 'include'
307
+ ? sourceFiles
308
+ : sourceFiles.filter((sourceFile) =>
309
+ draftModeIncludesFile(sourceFile.filePath, drafts)
310
+ );
311
+
312
+ const rootRelativeScopePath = (rootDir: string, filePath: string): string =>
313
+ relative(rootDir, filePath).replaceAll('\\', '/');
314
+
315
+ const filterSourceFilesByScope = (
316
+ sourceFiles: readonly SourceFile[],
317
+ rootDir: string,
318
+ scope: WardenScope
319
+ ): readonly SourceFile[] => {
320
+ if (scope.exclude.length === 0) {
321
+ return sourceFiles;
322
+ }
323
+
324
+ return sourceFiles.filter(
325
+ (sourceFile) =>
326
+ !matchesAnyPathGlob(
327
+ rootRelativeScopePath(rootDir, sourceFile.filePath),
328
+ scope.exclude
329
+ )
330
+ );
331
+ };
332
+
333
+ const EMPTY_WARDEN_SCOPE: WardenScope = { exclude: [] };
334
+
335
+ const collectDevPermitTestFiles = (
336
+ collection: NonNullable<ReturnType<typeof collectSourceTree>>
337
+ ): readonly string[] => {
338
+ const glob = new Bun.Glob('**/*.ts');
339
+ const files: string[] = [];
340
+ for (const file of collection.files) {
341
+ if (
342
+ !file.path.split('/').some((segment) => segment.startsWith('.')) &&
343
+ glob.match(file.path) &&
344
+ isWardenDevPermitTestScanTarget(file.path)
345
+ ) {
346
+ files.push(file.absolutePath);
347
+ }
348
+ }
349
+ return files;
350
+ };
351
+
352
+ const collectTextScanFiles = (
353
+ collection: NonNullable<ReturnType<typeof collectSourceTree>>
354
+ ): readonly string[] => [
355
+ ...collectFilesMatching(collection, '**/*.sh', true),
356
+ ...collectFilesMatching(collection, '**/*.bash', true),
357
+ ...collectFilesMatching(collection, '**/*.zsh', true),
358
+ ...collectFilesMatching(collection, '**/*.yml', true),
359
+ ...collectFilesMatching(collection, '**/*.yaml', true),
360
+ ...collectFilesMatching(collection, 'plugin/**/*.json', true),
361
+ ...collectFilesMatching(collection, 'plugin/**/*.jsonc', true),
362
+ ...collectFilesMatching(collection, 'plugin/**/*.toml', true),
363
+ ...collectFilesMatching(collection, 'plugin/**/*.cjs', true),
364
+ ...collectFilesMatching(collection, 'plugin/**/*.mjs', true),
365
+ ...collectFilesMatching(collection, 'scripts/**/*.json', true),
366
+ ...collectFilesMatching(collection, 'scripts/**/*.jsonc', true),
367
+ ...collectFilesMatching(collection, 'scripts/**/*.toml', true),
368
+ ...collectFilesMatching(collection, 'scripts/**/*.cjs', true),
369
+ ...collectFilesMatching(collection, 'scripts/**/*.mjs', true),
370
+ ...collectFilesMatching(collection, '**/package.json', true),
371
+ ];
372
+
373
+ const isDocumentationScanTarget = (match: string): boolean => {
374
+ if (
375
+ match === 'AGENTS.md' ||
376
+ match === 'README.md' ||
377
+ /^(?:packages|adapters|apps)(?:\/[^/]+)*\/AGENTS\.md$/.test(match)
378
+ ) {
379
+ return true;
380
+ }
381
+ if (/^(?:packages|adapters|apps)\/[^/]+\/README\.md$/.test(match)) {
382
+ return true;
383
+ }
384
+ return (
385
+ (match.startsWith('plugin/') ||
386
+ (match.startsWith('docs/') &&
387
+ !match.startsWith('docs/adr/') &&
388
+ !match.startsWith('docs/migration/') &&
389
+ !match.startsWith('docs/releases/'))) &&
390
+ match.endsWith('.md')
391
+ );
392
+ };
393
+
394
+ const collectDocumentationFiles = (
395
+ collection: NonNullable<ReturnType<typeof collectSourceTree>>
396
+ ): readonly string[] => {
397
+ const glob = new Bun.Glob('**/*.md');
398
+ const files: string[] = [];
399
+ for (const file of collection.files) {
400
+ if (
401
+ !file.path.split('/').some((segment) => segment.startsWith('.')) &&
402
+ glob.match(file.path) &&
403
+ isWardenSourceScanTarget(file.path) &&
404
+ isDocumentationScanTarget(file.path)
405
+ ) {
406
+ files.push(file.absolutePath);
407
+ }
408
+ }
409
+ return files;
410
+ };
411
+
412
+ interface SourceFile {
413
+ readonly filePath: string;
414
+ readonly kind: 'documentation' | 'text' | 'typescript';
415
+ readonly sourceCode: string;
416
+ }
417
+
418
+ interface MutableProjectContext {
419
+ authoredMcpSurfaceBindingSets:
420
+ | readonly AuthoredMcpSurfaceBindingSet[]
421
+ | undefined;
422
+ entityReferencesByName: Map<string, Set<string>>;
423
+ crudTableIds: Set<string>;
424
+ composeTargetTrailIds: Set<string>;
425
+ crudCoverageByEntity: Map<string, Set<string>>;
426
+ knownEntityIds: Set<string>;
427
+ knownResourceIds: Set<string>;
428
+ knownSignalIds: Set<string>;
429
+ knownTrailIds: Set<string>;
430
+ topoTrailIds: Set<string>;
431
+ importResolutionsByFile: Map<string, readonly WardenImportResolution[]>;
432
+ exportedSymbolDefinitionsByName: Map<
433
+ string,
434
+ readonly WardenExportedSymbolDefinition[]
435
+ >;
436
+ governedVocabularyHistoryByTransitionId: ProjectContext['governedVocabularyHistoryByTransitionId'];
437
+ governedVocabularyHistoryIssues: ProjectContext['governedVocabularyHistoryIssues'];
438
+ governedVocabularyHistoryRequired: ProjectContext['governedVocabularyHistoryRequired'];
439
+ documentedImportResolutionsByFile: Map<
440
+ string,
441
+ readonly WardenImportResolution[]
442
+ >;
443
+ onTargetSignalIds: Set<string>;
444
+ publicWorkspaces: ReturnType<typeof collectPublicWorkspaces>;
445
+ reconcileTableIds: Set<string>;
446
+ trailIntentsById: Map<string, 'destroy' | 'read' | 'write'>;
447
+ unownedWorkspaceLocks: readonly UnownedWorkspaceLockObservation[] | undefined;
448
+ }
449
+
450
+ const createMutableProjectContext = (): MutableProjectContext => ({
451
+ authoredMcpSurfaceBindingSets: undefined,
452
+ composeTargetTrailIds: new Set<string>(),
453
+ crudCoverageByEntity: new Map<string, Set<string>>(),
454
+ crudTableIds: new Set<string>(),
455
+ documentedImportResolutionsByFile: new Map<
456
+ string,
457
+ readonly WardenImportResolution[]
458
+ >(),
459
+ entityReferencesByName: new Map<string, Set<string>>(),
460
+ exportedSymbolDefinitionsByName: new Map(),
461
+ governedVocabularyHistoryByTransitionId: undefined,
462
+ governedVocabularyHistoryIssues: undefined,
463
+ governedVocabularyHistoryRequired: undefined,
464
+ importResolutionsByFile: new Map<string, readonly WardenImportResolution[]>(),
465
+ knownEntityIds: new Set<string>(),
466
+ knownResourceIds: new Set<string>(),
467
+ knownSignalIds: new Set<string>(),
468
+ knownTrailIds: new Set<string>(),
469
+ onTargetSignalIds: new Set<string>(),
470
+ publicWorkspaces: new Map(),
471
+ reconcileTableIds: new Set<string>(),
472
+ topoTrailIds: new Set<string>(),
473
+ trailIntentsById: new Map<string, 'destroy' | 'read' | 'write'>(),
474
+ unownedWorkspaceLocks: undefined,
475
+ });
476
+
477
+ const addEntityReferenceTargets = (
478
+ context: MutableProjectContext,
479
+ entityName: string,
480
+ targets: readonly string[]
481
+ ): void => {
482
+ const existing = context.entityReferencesByName.get(entityName);
483
+ if (existing) {
484
+ for (const target of targets) {
485
+ existing.add(target);
486
+ }
487
+ return;
488
+ }
489
+
490
+ context.entityReferencesByName.set(entityName, new Set(targets));
491
+ };
492
+
493
+ const toProjectContext = (context: MutableProjectContext): ProjectContext => ({
494
+ ...(context.unownedWorkspaceLocks === undefined
495
+ ? {}
496
+ : { unownedWorkspaceLocks: context.unownedWorkspaceLocks }),
497
+ ...(context.authoredMcpSurfaceBindingSets === undefined
498
+ ? {}
499
+ : { authoredMcpSurfaceBindingSets: context.authoredMcpSurfaceBindingSets }),
500
+ ...(context.entityReferencesByName.size > 0
501
+ ? {
502
+ entityReferencesByName: new Map(
503
+ [...context.entityReferencesByName.entries()].map(
504
+ ([name, targets]) => [name, [...targets]]
505
+ )
506
+ ),
507
+ }
508
+ : {}),
509
+ ...(context.crudTableIds.size > 0
510
+ ? { crudTableIds: context.crudTableIds }
511
+ : {}),
512
+ ...(context.crudCoverageByEntity.size > 0
513
+ ? {
514
+ crudCoverageByEntity: new Map(
515
+ [...context.crudCoverageByEntity.entries()].map(
516
+ ([entityId, operations]) => [
517
+ entityId,
518
+ new Set(operations) as ReadonlySet<string>,
519
+ ]
520
+ )
521
+ ),
522
+ }
523
+ : {}),
524
+ composeTargetTrailIds: context.composeTargetTrailIds,
525
+ ...(context.governedVocabularyHistoryByTransitionId === undefined
526
+ ? {}
527
+ : {
528
+ governedVocabularyHistoryByTransitionId:
529
+ context.governedVocabularyHistoryByTransitionId,
530
+ }),
531
+ ...(context.governedVocabularyHistoryIssues === undefined
532
+ ? {}
533
+ : {
534
+ governedVocabularyHistoryIssues:
535
+ context.governedVocabularyHistoryIssues,
536
+ }),
537
+ ...(context.governedVocabularyHistoryRequired === undefined
538
+ ? {}
539
+ : {
540
+ governedVocabularyHistoryRequired:
541
+ context.governedVocabularyHistoryRequired,
542
+ }),
543
+ knownEntityIds: context.knownEntityIds,
544
+ knownResourceIds: context.knownResourceIds,
545
+ knownSignalIds: context.knownSignalIds,
546
+ knownTrailIds: context.knownTrailIds,
547
+ ...(context.topoTrailIds.size > 0
548
+ ? { topoTrailIds: context.topoTrailIds }
549
+ : {}),
550
+ ...(context.importResolutionsByFile.size > 0
551
+ ? { importResolutionsByFile: context.importResolutionsByFile }
552
+ : {}),
553
+ ...(context.documentedImportResolutionsByFile.size > 0
554
+ ? {
555
+ documentedImportResolutionsByFile:
556
+ context.documentedImportResolutionsByFile,
557
+ }
558
+ : {}),
559
+ ...(context.exportedSymbolDefinitionsByName.size > 0
560
+ ? {
561
+ exportedSymbolDefinitionsByName:
562
+ context.exportedSymbolDefinitionsByName,
563
+ }
564
+ : {}),
565
+ ...(context.onTargetSignalIds.size > 0
566
+ ? { onTargetSignalIds: context.onTargetSignalIds }
567
+ : {}),
568
+ ...(context.publicWorkspaces.size > 0
569
+ ? { publicWorkspaces: context.publicWorkspaces }
570
+ : {}),
571
+ ...(context.reconcileTableIds.size > 0
572
+ ? { reconcileTableIds: context.reconcileTableIds }
573
+ : {}),
574
+ trailIntentsById: context.trailIntentsById,
575
+ });
576
+
577
+ const collectKnownEntityIds = (
578
+ sourceCode: string,
579
+ filePath: string,
580
+ knownEntityIds: Set<string>
581
+ ): void => {
582
+ const ast = parse(filePath, sourceCode);
583
+ if (!ast) {
584
+ return;
585
+ }
586
+ for (const id of collectEntityDefinitionIds(ast)) {
587
+ knownEntityIds.add(id);
588
+ }
589
+ };
590
+
591
+ const collectKnownTrailIds = (
592
+ sourceCode: string,
593
+ filePath: string,
594
+ knownTrailIds: Set<string>
595
+ ): void => {
596
+ const ast = parse(filePath, sourceCode);
597
+ if (!ast) {
598
+ return;
599
+ }
600
+ for (const def of findTrailDefinitions(ast)) {
601
+ knownTrailIds.add(def.id);
602
+ }
603
+ };
604
+
605
+ const collectComposedTrailIds = (
606
+ sourceCode: string,
607
+ filePath: string,
608
+ composeTargetTrailIds: Set<string>
609
+ ): void => {
610
+ const ast = parse(filePath, sourceCode);
611
+ if (!ast) {
612
+ return;
613
+ }
614
+ for (const id of collectComposeTargetTrailIds(ast, sourceCode)) {
615
+ composeTargetTrailIds.add(id);
616
+ }
617
+ };
618
+
619
+ const collectKnownResourceIds = (
620
+ sourceCode: string,
621
+ filePath: string,
622
+ knownResourceIds: Set<string>
623
+ ): void => {
624
+ const ast = parse(filePath, sourceCode);
625
+ if (!ast) {
626
+ return;
627
+ }
628
+ for (const id of collectResourceDefinitionIds(ast)) {
629
+ knownResourceIds.add(id);
630
+ }
631
+ };
632
+
633
+ const collectKnownSignalIds = (
634
+ sourceCode: string,
635
+ filePath: string,
636
+ knownSignalIds: Set<string>
637
+ ): void => {
638
+ const ast = parse(filePath, sourceCode);
639
+ if (!ast) {
640
+ return;
641
+ }
642
+ for (const id of collectSignalDefinitionIds(ast)) {
643
+ knownSignalIds.add(id);
644
+ }
645
+ };
646
+
647
+ const collectTrailIntents = (
648
+ sourceCode: string,
649
+ filePath: string,
650
+ trailIntentsById: Map<string, 'destroy' | 'read' | 'write'>
651
+ ): void => {
652
+ const ast = parse(filePath, sourceCode);
653
+ if (!ast) {
654
+ return;
655
+ }
656
+ for (const [id, intent] of collectTrailIntentsById(ast)) {
657
+ trailIntentsById.set(id, intent);
658
+ }
659
+ };
660
+
661
+ const collectCrudTableIds = (
662
+ sourceCode: string,
663
+ filePath: string,
664
+ crudTableIds: Set<string>
665
+ ): void => {
666
+ const ast = parse(filePath, sourceCode);
667
+ if (!ast) {
668
+ return;
669
+ }
670
+ for (const id of collectCrudTableIdsFromAst(ast)) {
671
+ crudTableIds.add(id);
672
+ }
673
+ };
674
+
675
+ const collectOnTargetSignalIds = (
676
+ sourceCode: string,
677
+ filePath: string,
678
+ onTargetSignalIds: Set<string>
679
+ ): void => {
680
+ const ast = parse(filePath, sourceCode);
681
+ if (!ast) {
682
+ return;
683
+ }
684
+ for (const id of collectOnTargetSignalIdsFromAst(ast, sourceCode)) {
685
+ onTargetSignalIds.add(id);
686
+ }
687
+ };
688
+
689
+ const collectCrudCoverageByEntity = (
690
+ sourceCode: string,
691
+ filePath: string,
692
+ coverageByEntity: Map<string, Set<string>>
693
+ ): void => {
694
+ const ast = parse(filePath, sourceCode);
695
+ if (!ast) {
696
+ return;
697
+ }
698
+ for (const [entityId, operations] of collectFileCrudCoverage(
699
+ ast,
700
+ sourceCode
701
+ )) {
702
+ const bucket = coverageByEntity.get(entityId) ?? new Set<string>();
703
+ for (const operation of operations) {
704
+ bucket.add(operation);
705
+ }
706
+ coverageByEntity.set(entityId, bucket);
707
+ }
708
+ };
709
+
710
+ const collectReconcileTableIds = (
711
+ sourceCode: string,
712
+ filePath: string,
713
+ reconcileTableIds: Set<string>
714
+ ): void => {
715
+ const ast = parse(filePath, sourceCode);
716
+ if (!ast) {
717
+ return;
718
+ }
719
+ for (const id of collectReconcileTableIdsFromAst(ast)) {
720
+ reconcileTableIds.add(id);
721
+ }
722
+ };
723
+
724
+ interface LoadedSourceFiles {
725
+ readonly collection: SourceTreeCollection | null;
726
+ readonly sourceFiles: readonly SourceFile[];
727
+ }
728
+
729
+ const loadSourceFiles = async (rootDir: string): Promise<LoadedSourceFiles> => {
730
+ const sourceFiles: SourceFile[] = [];
731
+ const collection = collectSourceTree(rootDir, {
732
+ classify: (entry) => {
733
+ if (
734
+ entry.kind === 'directory' &&
735
+ ['.git', 'dist', 'node_modules'].includes(entry.name)
736
+ ) {
737
+ return { action: 'skip', reason: 'ignored-directory' };
738
+ }
739
+ if (entry.kind === 'directory') {
740
+ return { action: 'recurse' };
741
+ }
742
+ return entry.kind === 'file'
743
+ ? { action: 'collect' }
744
+ : { action: 'skip', reason: 'unsupported-entry' };
745
+ },
746
+ });
747
+ if (!collection) {
748
+ return { collection, sourceFiles };
749
+ }
750
+
751
+ for (const filePath of collectTsFiles(collection)) {
752
+ try {
753
+ sourceFiles.push({
754
+ filePath,
755
+ kind: 'typescript',
756
+ sourceCode: await Bun.file(filePath).text(),
757
+ });
758
+ } catch {
759
+ continue;
760
+ }
761
+ }
762
+
763
+ for (const filePath of collectTextScanFiles(collection)) {
764
+ try {
765
+ sourceFiles.push({
766
+ filePath,
767
+ kind: 'text',
768
+ sourceCode: await Bun.file(filePath).text(),
769
+ });
770
+ } catch {
771
+ continue;
772
+ }
773
+ }
774
+
775
+ for (const filePath of collectDocumentationFiles(collection)) {
776
+ try {
777
+ sourceFiles.push({
778
+ filePath,
779
+ kind: 'documentation',
780
+ sourceCode: await Bun.file(filePath).text(),
781
+ });
782
+ } catch {
783
+ continue;
784
+ }
785
+ }
786
+
787
+ for (const filePath of collectDevPermitTestFiles(collection)) {
788
+ try {
789
+ sourceFiles.push({
790
+ filePath,
791
+ kind: 'text',
792
+ sourceCode: await Bun.file(filePath).text(),
793
+ });
794
+ } catch {
795
+ continue;
796
+ }
797
+ }
798
+
799
+ return { collection, sourceFiles };
800
+ };
801
+
802
+ const collectTopoKnownIds = (
803
+ appTopo: Topo,
804
+ context: MutableProjectContext
805
+ ): void => {
806
+ for (const name of appTopo.entities.keys()) {
807
+ context.knownEntityIds.add(name);
808
+ }
809
+
810
+ for (const id of appTopo.trails.keys()) {
811
+ context.knownTrailIds.add(id);
812
+ context.topoTrailIds.add(id);
813
+ }
814
+
815
+ for (const id of appTopo.resources.keys()) {
816
+ context.knownResourceIds.add(id);
817
+ }
818
+
819
+ for (const id of appTopo.signals.keys()) {
820
+ context.knownSignalIds.add(id);
821
+ }
822
+ };
823
+
824
+ const collectTopoComposesAndIntents = (
825
+ appTopo: Topo,
826
+ context: MutableProjectContext
827
+ ): void => {
828
+ for (const trail of appTopo.trails.values()) {
829
+ context.trailIntentsById.set(trail.id, trail.intent);
830
+ for (const composedTrailId of trail.composes) {
831
+ context.composeTargetTrailIds.add(composedTrailId);
832
+ }
833
+ }
834
+ };
835
+
836
+ const collectTopoEntityReferences = (
837
+ appTopo: Topo,
838
+ context: MutableProjectContext
839
+ ): void => {
840
+ for (const entity of appTopo.listEntities()) {
841
+ addEntityReferenceTargets(
842
+ context,
843
+ entity.name,
844
+ getEntityReferences(entity).map((reference) => reference.entity)
845
+ );
846
+ }
847
+ };
848
+
849
+ const collectTopoTrailContext = (
850
+ appTopo: Topo,
851
+ context: MutableProjectContext
852
+ ): void => {
853
+ collectTopoKnownIds(appTopo, context);
854
+ collectTopoComposesAndIntents(appTopo, context);
855
+ collectTopoEntityReferences(appTopo, context);
856
+ };
857
+
858
+ const collectFileKnownIds = (
859
+ sourceFile: SourceFile,
860
+ context: MutableProjectContext
861
+ ): void => {
862
+ collectKnownEntityIds(
863
+ sourceFile.sourceCode,
864
+ sourceFile.filePath,
865
+ context.knownEntityIds
866
+ );
867
+ collectKnownTrailIds(
868
+ sourceFile.sourceCode,
869
+ sourceFile.filePath,
870
+ context.knownTrailIds
871
+ );
872
+ collectKnownResourceIds(
873
+ sourceFile.sourceCode,
874
+ sourceFile.filePath,
875
+ context.knownResourceIds
876
+ );
877
+ collectKnownSignalIds(
878
+ sourceFile.sourceCode,
879
+ sourceFile.filePath,
880
+ context.knownSignalIds
881
+ );
882
+ };
883
+
884
+ const collectFileTrailRelationships = (
885
+ sourceFile: SourceFile,
886
+ context: MutableProjectContext
887
+ ): void => {
888
+ collectComposedTrailIds(
889
+ sourceFile.sourceCode,
890
+ sourceFile.filePath,
891
+ context.composeTargetTrailIds
892
+ );
893
+ collectTrailIntents(
894
+ sourceFile.sourceCode,
895
+ sourceFile.filePath,
896
+ context.trailIntentsById
897
+ );
898
+ };
899
+
900
+ const collectFileSupplementalProjectContext = (
901
+ sourceFile: SourceFile,
902
+ context: MutableProjectContext
903
+ ): void => {
904
+ collectCrudTableIds(
905
+ sourceFile.sourceCode,
906
+ sourceFile.filePath,
907
+ context.crudTableIds
908
+ );
909
+ collectOnTargetSignalIds(
910
+ sourceFile.sourceCode,
911
+ sourceFile.filePath,
912
+ context.onTargetSignalIds
913
+ );
914
+ collectReconcileTableIds(
915
+ sourceFile.sourceCode,
916
+ sourceFile.filePath,
917
+ context.reconcileTableIds
918
+ );
919
+ collectCrudCoverageByEntity(
920
+ sourceFile.sourceCode,
921
+ sourceFile.filePath,
922
+ context.crudCoverageByEntity
923
+ );
924
+ };
925
+
926
+ const collectFileProjectContext = (
927
+ sourceFile: SourceFile,
928
+ context: MutableProjectContext
929
+ ): void => {
930
+ collectFileKnownIds(sourceFile, context);
931
+ collectFileTrailRelationships(sourceFile, context);
932
+ collectFileSupplementalProjectContext(sourceFile, context);
933
+ };
934
+
935
+ const collectFileEntityReferences = (
936
+ sourceFile: SourceFile,
937
+ context: MutableProjectContext
938
+ ): void => {
939
+ const ast = parse(sourceFile.filePath, sourceFile.sourceCode);
940
+ if (!ast) {
941
+ return;
942
+ }
943
+
944
+ const referencesByName = collectEntityReferenceTargetsByName(
945
+ ast,
946
+ context.knownEntityIds
947
+ );
948
+ for (const [entityName, targets] of referencesByName) {
949
+ addEntityReferenceTargets(context, entityName, targets);
950
+ }
951
+ };
952
+
953
+ const collectFileImportResolutions = (
954
+ rootDir: string,
955
+ sourceFiles: readonly SourceFile[],
956
+ context: MutableProjectContext
957
+ ): void => {
958
+ const resolutionsByFile = collectProjectImportResolutions({
959
+ publicWorkspaces: context.publicWorkspaces,
960
+ rootDir,
961
+ sourceFiles,
962
+ });
963
+ for (const [filePath, resolutions] of resolutionsByFile) {
964
+ context.importResolutionsByFile.set(filePath, resolutions);
965
+ }
966
+ };
967
+
968
+ const collectFileDocumentedImportResolutions = (
969
+ rootDir: string,
970
+ sourceFiles: readonly SourceFile[],
971
+ context: MutableProjectContext
972
+ ): void => {
973
+ const resolutionsByFile = collectProjectDocumentationImportResolutions({
974
+ publicWorkspaces: context.publicWorkspaces,
975
+ rootDir,
976
+ sourceFiles,
977
+ });
978
+ for (const [filePath, resolutions] of resolutionsByFile) {
979
+ context.documentedImportResolutionsByFile.set(filePath, resolutions);
980
+ }
981
+ };
982
+
983
+ const collectFileExportedSymbolDefinitions = (
984
+ rootDir: string,
985
+ sourceFiles: readonly SourceFile[],
986
+ context: MutableProjectContext
987
+ ): void => {
988
+ const definitionsByName = collectProjectExportedSymbolDefinitions({
989
+ publicWorkspaces: context.publicWorkspaces,
990
+ rootDir,
991
+ sourceFiles,
992
+ });
993
+ for (const [name, definitions] of definitionsByName) {
994
+ context.exportedSymbolDefinitionsByName.set(name, definitions);
995
+ }
996
+ };
997
+
998
+ /**
999
+ * Resolve per-app authored `mcp` surface bindings across the run's topo
1000
+ * targets, so project-aware source rules can compare call-site surface
1001
+ * options against the authored default of the app they belong to.
1002
+ *
1003
+ * Prefers the serialized graph overlays when a target carries a precomputed
1004
+ * graph; otherwise reads the app-module overlay registrations. Invalid
1005
+ * overlays are skipped here — `surface-overlay-coherence` reports them on
1006
+ * the topo-aware path.
1007
+ */
1008
+ const collectAuthoredMcpSurfaceBindingSets = (
1009
+ topoTargets: readonly WardenTopoTarget[]
1010
+ ): readonly AuthoredMcpSurfaceBindingSet[] | undefined => {
1011
+ const sets: AuthoredMcpSurfaceBindingSet[] = [];
1012
+ for (const target of topoTargets) {
1013
+ let bindings: SurfaceBindings | undefined;
1014
+ try {
1015
+ const graphOverlays = target.graph?.overlays;
1016
+ bindings =
1017
+ graphOverlays === undefined
1018
+ ? resolveSurfaceOverlayBindings(target.overlays)?.mcp
1019
+ : surfaceBindingsFromLockOverlays(graphOverlays)?.mcp;
1020
+ } catch {
1021
+ continue;
1022
+ }
1023
+ if (bindings === undefined) {
1024
+ continue;
1025
+ }
1026
+ sets.push({
1027
+ appName: target.name ?? target.topo.name,
1028
+ bindings,
1029
+ trailIds: [...target.topo.trails.keys()],
1030
+ });
1031
+ }
1032
+ return sets.length > 0 ? sets : undefined;
1033
+ };
1034
+
1035
+ const isFileUnderRoot = (filePath: string, rootDir: string): boolean => {
1036
+ const relativePath = relative(rootDir, filePath);
1037
+ return (
1038
+ relativePath !== '' &&
1039
+ !relativePath.startsWith('..') &&
1040
+ !isAbsolute(relativePath)
1041
+ );
1042
+ };
1043
+
1044
+ /**
1045
+ * Resolve the app-local roots that loaded topo targets govern.
1046
+ *
1047
+ * Returns `null` when any target lacks an app-local root: a root-less target
1048
+ * (single-app runs, embedder fixtures) keeps the historical behavior of
1049
+ * governing the entire scan, so no file-authored known-id supplement applies.
1050
+ */
1051
+ const governedTargetRoots = (
1052
+ topoTargets: readonly WardenTopoTarget[],
1053
+ rootDir: string
1054
+ ): readonly string[] | null => {
1055
+ const targetRoots = topoTargets.flatMap((target) =>
1056
+ target.rootDir === undefined ? [] : [resolve(rootDir, target.rootDir)]
1057
+ );
1058
+ return targetRoots.length === topoTargets.length ? targetRoots : null;
1059
+ };
1060
+
1061
+ const buildProjectContext = (
1062
+ sourceFiles: readonly SourceFile[],
1063
+ collection: SourceTreeCollection | null,
1064
+ rootDir: string,
1065
+ topoTargets: readonly WardenTopoTarget[] = [],
1066
+ scope: WardenScope = EMPTY_WARDEN_SCOPE,
1067
+ authoredMcpSurfaceBindingSets:
1068
+ | readonly AuthoredMcpSurfaceBindingSet[]
1069
+ | undefined = undefined,
1070
+ unownedWorkspaceLocks:
1071
+ | readonly UnownedWorkspaceLockObservation[]
1072
+ | undefined = undefined
1073
+ ): ProjectContext => {
1074
+ const context = createMutableProjectContext();
1075
+ const governedHistory = loadGovernedVocabularyHistory(rootDir);
1076
+ context.governedVocabularyHistoryByTransitionId =
1077
+ governedHistory.byTransitionId;
1078
+ context.governedVocabularyHistoryIssues = governedHistory.issues;
1079
+ context.authoredMcpSurfaceBindingSets = authoredMcpSurfaceBindingSets;
1080
+ context.unownedWorkspaceLocks = unownedWorkspaceLocks;
1081
+ const typeScriptSourceFiles = sourceFiles.filter(
1082
+ (sourceFile) => sourceFile.kind === 'typescript'
1083
+ );
1084
+ const documentationSourceFiles = sourceFiles.filter(
1085
+ (sourceFile) => sourceFile.kind === 'documentation'
1086
+ );
1087
+ context.publicWorkspaces = collectPublicWorkspaces(rootDir, {
1088
+ collectedPackageJsonPaths: new Set(
1089
+ collection?.files
1090
+ .filter((file) => file.path.endsWith('package.json'))
1091
+ .map((file) => file.path)
1092
+ ),
1093
+ exclude: scope.exclude,
1094
+ });
1095
+ context.governedVocabularyHistoryRequired =
1096
+ context.publicWorkspaces.has('@ontrails/warden');
1097
+
1098
+ if (topoTargets.length > 0) {
1099
+ for (const target of topoTargets) {
1100
+ collectTopoTrailContext(target.topo, context);
1101
+ }
1102
+ // Loaded topos stay authoritative for the files under their app-local
1103
+ // roots, but a workspace scan can cover configured apps outside a
1104
+ // narrowed `--app` selection. Supplement the known-id sets with
1105
+ // file-authored definitions for files no loaded target governs so
1106
+ // existence rules (`on-references-exist`, `resource-exists`,
1107
+ // `entity-exists`) do not flag honest references in unselected apps.
1108
+ // Strict topo membership stays on `topoTrailIds`, topo-derived only.
1109
+ const governedRoots = governedTargetRoots(topoTargets, rootDir);
1110
+ for (const sourceFile of typeScriptSourceFiles) {
1111
+ if (
1112
+ governedRoots !== null &&
1113
+ !governedRoots.some((root) =>
1114
+ isFileUnderRoot(sourceFile.filePath, root)
1115
+ )
1116
+ ) {
1117
+ collectFileKnownIds(sourceFile, context);
1118
+ }
1119
+ collectFileSupplementalProjectContext(sourceFile, context);
1120
+ }
1121
+ } else {
1122
+ for (const sourceFile of typeScriptSourceFiles) {
1123
+ collectFileProjectContext(sourceFile, context);
1124
+ }
1125
+ }
1126
+
1127
+ for (const sourceFile of typeScriptSourceFiles) {
1128
+ collectFileEntityReferences(sourceFile, context);
1129
+ }
1130
+ collectFileImportResolutions(rootDir, typeScriptSourceFiles, context);
1131
+ collectFileDocumentedImportResolutions(
1132
+ rootDir,
1133
+ documentationSourceFiles,
1134
+ context
1135
+ );
1136
+ collectFileExportedSymbolDefinitions(rootDir, typeScriptSourceFiles, context);
1137
+
1138
+ return toProjectContext(context);
1139
+ };
1140
+
1141
+ const isProjectAwareRule = (rule: WardenRule): rule is ProjectAwareWardenRule =>
1142
+ 'checkWithContext' in rule;
1143
+
1144
+ const createOptionsDiagnostic = (message: string): WardenDiagnostic => ({
1145
+ filePath: '<warden-options>',
1146
+ line: 1,
1147
+ message,
1148
+ rule: 'warden-options',
1149
+ severity: 'error',
1150
+ });
1151
+
1152
+ const emptyProjectWardenRules = (): ProjectWardenRules => ({
1153
+ diagnostics: [],
1154
+ sourceRules: [],
1155
+ topoRules: [],
1156
+ });
1157
+
1158
+ const loadProjectRulesForRun = async (
1159
+ rootDir: string,
1160
+ options: WardenRunOptions,
1161
+ runLint: boolean
1162
+ ): Promise<ProjectWardenRules> => {
1163
+ if (!runLint || options.projectRules === false) {
1164
+ return emptyProjectWardenRules();
1165
+ }
1166
+ return loadProjectWardenRules(rootDir);
1167
+ };
1168
+
1169
+ interface WardenRuleSelector {
1170
+ readonly depth?: WardenDepth | undefined;
1171
+ readonly tier?: WardenRuleTier | undefined;
1172
+ }
1173
+
1174
+ const depthIncludesTier = (
1175
+ depth: WardenDepth,
1176
+ tier: WardenRuleTier
1177
+ ): boolean => {
1178
+ switch (depth) {
1179
+ case 'source': {
1180
+ return tier === 'source-static';
1181
+ }
1182
+ case 'project': {
1183
+ return tier === 'source-static' || tier === 'project-static';
1184
+ }
1185
+ case 'topo': {
1186
+ return (
1187
+ tier === 'source-static' ||
1188
+ tier === 'project-static' ||
1189
+ tier === 'topo-aware'
1190
+ );
1191
+ }
1192
+ case 'all': {
1193
+ return true;
1194
+ }
1195
+ default: {
1196
+ return false;
1197
+ }
1198
+ }
1199
+ };
1200
+
1201
+ const ruleMatchesTier = (
1202
+ metadata: ReturnType<typeof getWardenRuleMetadata>,
1203
+ tier: WardenRuleTier | undefined
1204
+ ): boolean => {
1205
+ if (!tier) {
1206
+ return true;
1207
+ }
1208
+
1209
+ if (!metadata) {
1210
+ return false;
1211
+ }
1212
+
1213
+ return tier === 'advisory'
1214
+ ? metadata.scope === 'advisory'
1215
+ : metadata.tier === tier;
1216
+ };
1217
+
1218
+ const ruleMatchesDepth = (
1219
+ metadata: ReturnType<typeof getWardenRuleMetadata>,
1220
+ depth: WardenDepth | undefined
1221
+ ): boolean => {
1222
+ if (!depth) {
1223
+ return true;
1224
+ }
1225
+
1226
+ if (!metadata) {
1227
+ return false;
1228
+ }
1229
+
1230
+ if (metadata.scope === 'advisory') {
1231
+ return depth === 'all';
1232
+ }
1233
+
1234
+ return depthIncludesTier(depth, metadata.tier);
1235
+ };
1236
+
1237
+ const isSelectedRule = (
1238
+ rule: WardenRule | TopoAwareWardenRule,
1239
+ selector: WardenRuleSelector
1240
+ ): boolean => {
1241
+ const metadata = getWardenRuleMetadata(rule);
1242
+ return selector.tier
1243
+ ? ruleMatchesTier(metadata, selector.tier)
1244
+ : ruleMatchesDepth(metadata, selector.depth);
1245
+ };
1246
+
1247
+ const selectorIncludesProjectChecks = (
1248
+ selector: WardenRuleSelector
1249
+ ): boolean => {
1250
+ if (selector.tier !== undefined) {
1251
+ return selector.tier !== 'source-static';
1252
+ }
1253
+ return (
1254
+ selector.depth === undefined ||
1255
+ depthIncludesTier(selector.depth, 'project-static')
1256
+ );
1257
+ };
1258
+
1259
+ const isSelectedProjectRule = (
1260
+ rule: WardenRule,
1261
+ selector: WardenRuleSelector
1262
+ ): boolean =>
1263
+ selectorIncludesProjectChecks(selector) &&
1264
+ (selector.tier === 'project-static' || isSelectedRule(rule, selector));
1265
+
1266
+ const isSelectedTopoRule = (
1267
+ rule: TopoAwareWardenRule,
1268
+ selector: WardenRuleSelector
1269
+ ): boolean => {
1270
+ const metadata = getWardenRuleMetadata(rule);
1271
+ if (selector.tier) {
1272
+ return metadata
1273
+ ? ruleMatchesTier(metadata, selector.tier)
1274
+ : selector.tier === 'topo-aware';
1275
+ }
1276
+
1277
+ return metadata ? ruleMatchesDepth(metadata, selector.depth) : true;
1278
+ };
1279
+
1280
+ const withDiagnosticGuidance = (
1281
+ diagnostic: WardenDiagnostic
1282
+ ): WardenDiagnostic => {
1283
+ if (diagnostic.guidance !== undefined) {
1284
+ return diagnostic;
1285
+ }
1286
+
1287
+ const guidance = getWardenRuleMetadata(diagnostic.rule)?.guidance;
1288
+ return guidance === undefined ? diagnostic : { ...diagnostic, guidance };
1289
+ };
1290
+
1291
+ const topoRuleFailureDiagnostic = (
1292
+ rule: TopoAwareWardenRule,
1293
+ error: unknown
1294
+ ): WardenDiagnostic => {
1295
+ const cause = error instanceof Error ? error : new Error(String(error));
1296
+ return {
1297
+ filePath: '<topo>',
1298
+ line: 1,
1299
+ message: `Topo-aware rule "${rule.name}" threw: ${cause.message}`,
1300
+ rule: rule.name,
1301
+ severity: 'error',
1302
+ };
1303
+ };
1304
+
1305
+ /**
1306
+ * Run all registered topo-aware rules against the resolved topo.
1307
+ *
1308
+ * Topo-aware rules fire exactly once per run (not per file) because they
1309
+ * inspect the compiled trail graph, not source text.
1310
+ */
1311
+ const lintTopo = async (
1312
+ appTopo: Topo,
1313
+ graph: TopoGraph | undefined,
1314
+ overlays: readonly TopoGraphOverlayRegistration[] | undefined,
1315
+ extraTopoRules: readonly TopoAwareWardenRule[],
1316
+ selector: WardenRuleSelector
1317
+ ): Promise<readonly WardenDiagnostic[]> => {
1318
+ const diagnostics: WardenDiagnostic[] = [];
1319
+ const rules: readonly TopoAwareWardenRule[] = [
1320
+ ...wardenTopoRules.values(),
1321
+ ...extraTopoRules,
1322
+ ].filter((rule) => isSelectedTopoRule(rule, selector));
1323
+ let contextGraph: TopoGraph;
1324
+ try {
1325
+ contextGraph = graph ?? deriveTopoGraph(appTopo, { overlays });
1326
+ } catch (error) {
1327
+ for (const rule of rules) {
1328
+ diagnostics.push(topoRuleFailureDiagnostic(rule, error));
1329
+ }
1330
+ return diagnostics;
1331
+ }
1332
+
1333
+ for (const rule of rules) {
1334
+ try {
1335
+ diagnostics.push(
1336
+ ...(await rule.checkTopo(appTopo, { graph: contextGraph }))
1337
+ );
1338
+ } catch (error) {
1339
+ diagnostics.push(topoRuleFailureDiagnostic(rule, error));
1340
+ }
1341
+ }
1342
+ return diagnostics;
1343
+ };
1344
+
1345
+ const lintSourceFiles = (
1346
+ sourceFiles: readonly SourceFile[],
1347
+ context: ProjectContext,
1348
+ extraSourceRules: readonly WardenRule[],
1349
+ selector: WardenRuleSelector
1350
+ ): readonly WardenDiagnostic[] => {
1351
+ const diagnostics: WardenDiagnostic[] = [];
1352
+ const rules = [...wardenRules.values(), ...extraSourceRules];
1353
+ for (const rule of rules) {
1354
+ if (
1355
+ isSelectedProjectRule(rule, selector) &&
1356
+ isProjectAwareRule(rule) &&
1357
+ rule.checkProject !== undefined
1358
+ ) {
1359
+ diagnostics.push(...rule.checkProject(context));
1360
+ }
1361
+ }
1362
+ for (const sourceFile of sourceFiles) {
1363
+ for (const rule of rules) {
1364
+ if (
1365
+ sourceFile.kind === 'text' &&
1366
+ !rule.sourceKinds?.includes('text') &&
1367
+ rule.name !== 'no-dev-permit-in-source' &&
1368
+ rule.name !== 'public-internal-deep-imports'
1369
+ ) {
1370
+ continue;
1371
+ }
1372
+
1373
+ if (
1374
+ sourceFile.kind === 'documentation' &&
1375
+ !rule.sourceKinds?.includes('documentation') &&
1376
+ rule.name !== 'public-internal-deep-imports'
1377
+ ) {
1378
+ continue;
1379
+ }
1380
+
1381
+ if (!isSelectedRule(rule, selector)) {
1382
+ continue;
1383
+ }
1384
+
1385
+ if (isProjectAwareRule(rule)) {
1386
+ diagnostics.push(
1387
+ ...rule.checkWithContext(
1388
+ sourceFile.sourceCode,
1389
+ sourceFile.filePath,
1390
+ context
1391
+ )
1392
+ );
1393
+ continue;
1394
+ }
1395
+ diagnostics.push(
1396
+ ...rule.check(sourceFile.sourceCode, sourceFile.filePath)
1397
+ );
1398
+ }
1399
+ }
1400
+ return diagnostics;
1401
+ };
1402
+
1403
+ const tagTopoDiagnostic = (
1404
+ diagnostic: WardenDiagnostic,
1405
+ topoName: string | undefined
1406
+ ): WardenDiagnostic =>
1407
+ topoName === undefined ? diagnostic : { ...diagnostic, topoName };
1408
+
1409
+ const lintTopoTargets = async (
1410
+ topoTargets: readonly WardenTopoTarget[],
1411
+ extraTopoRules: readonly TopoAwareWardenRule[],
1412
+ selector: WardenRuleSelector,
1413
+ tagDiagnostics: boolean
1414
+ ): Promise<readonly WardenDiagnostic[]> => {
1415
+ const diagnostics: WardenDiagnostic[] = [];
1416
+
1417
+ for (const target of topoTargets) {
1418
+ const topoDiagnostics = await lintTopo(
1419
+ target.topo,
1420
+ target.graph,
1421
+ target.overlays,
1422
+ extraTopoRules,
1423
+ selector
1424
+ );
1425
+ const topoName = target.name ?? target.topo.name;
1426
+ diagnostics.push(
1427
+ ...(tagDiagnostics
1428
+ ? topoDiagnostics.map((diagnostic) =>
1429
+ tagTopoDiagnostic(diagnostic, topoName)
1430
+ )
1431
+ : topoDiagnostics)
1432
+ );
1433
+ }
1434
+
1435
+ return diagnostics;
1436
+ };
1437
+
1438
+ const selectorIncludesTopoRules = (selector: WardenRuleSelector): boolean => {
1439
+ if (selector.tier) {
1440
+ return selector.tier === 'advisory';
1441
+ }
1442
+
1443
+ return !selector.depth || depthIncludesTier(selector.depth, 'topo-aware');
1444
+ };
1445
+
1446
+ /**
1447
+ * Lint all files against all warden rules.
1448
+ */
1449
+ interface WardenLintResult {
1450
+ readonly diagnostics: readonly WardenDiagnostic[];
1451
+ readonly sourceFiles: readonly SourceFile[];
1452
+ }
1453
+
1454
+ interface WorkspaceLockCollectionResult {
1455
+ readonly diagnostics: readonly WardenDiagnostic[];
1456
+ readonly observations?:
1457
+ | readonly UnownedWorkspaceLockObservation[]
1458
+ | undefined;
1459
+ }
1460
+
1461
+ const collectUnownedWorkspaceLocks = async (
1462
+ rootDir: string,
1463
+ scope: WardenScope,
1464
+ configPath: string | undefined
1465
+ ): Promise<WorkspaceLockCollectionResult> => {
1466
+ try {
1467
+ const identity = await readTrailsProjectIdentity({
1468
+ boundaryDir: rootDir,
1469
+ configPath,
1470
+ startDir: rootDir,
1471
+ });
1472
+ if (identity.workspace === undefined) {
1473
+ return { diagnostics: [] };
1474
+ }
1475
+ const workspace = await deriveWorkspaceView({ identity });
1476
+ const observations = workspace.evidence.unownedLocks
1477
+ .map((observation) => ({
1478
+ ...observation,
1479
+ path: rootRelativeScopePath(
1480
+ rootDir,
1481
+ resolve(identity.rootDir, observation.path)
1482
+ ),
1483
+ }))
1484
+ .filter(
1485
+ (observation) => !matchesAnyPathGlob(observation.path, scope.exclude)
1486
+ );
1487
+ return {
1488
+ diagnostics: [],
1489
+ observations,
1490
+ };
1491
+ } catch (error) {
1492
+ return {
1493
+ diagnostics: [
1494
+ {
1495
+ filePath: configPath ?? rootDir,
1496
+ line: 1,
1497
+ message: `Failed to inspect Trails project identity for workspace lock ownership: ${error instanceof Error ? error.message : String(error)}`,
1498
+ rule: 'warden-config',
1499
+ severity: 'error',
1500
+ },
1501
+ ],
1502
+ };
1503
+ }
1504
+ };
1505
+
1506
+ const lintFiles = async (
1507
+ rootDir: string,
1508
+ drafts: EffectiveWardenConfig['drafts'],
1509
+ scope: EffectiveWardenConfig['scope'],
1510
+ topoTargets: readonly WardenTopoTarget[],
1511
+ extraTopoRules: readonly TopoAwareWardenRule[],
1512
+ extraSourceRules: readonly WardenRule[],
1513
+ selector: WardenRuleSelector,
1514
+ configPath: string | undefined
1515
+ ): Promise<WardenLintResult> => {
1516
+ if (selector.tier === 'topo-aware') {
1517
+ return {
1518
+ diagnostics: [
1519
+ ...(await lintTopoTargets(topoTargets, extraTopoRules, selector, true)),
1520
+ ],
1521
+ sourceFiles: [],
1522
+ };
1523
+ }
1524
+
1525
+ const loaded = await loadSourceFiles(rootDir);
1526
+ const sourceFiles = filterSourceFilesByScope(
1527
+ filterSourceFilesByDraftMode(loaded.sourceFiles, drafts),
1528
+ rootDir,
1529
+ scope
1530
+ );
1531
+ const workspaceLocks = selectorIncludesProjectChecks(selector)
1532
+ ? await collectUnownedWorkspaceLocks(rootDir, scope, configPath)
1533
+ : { diagnostics: [] };
1534
+ const context = buildProjectContext(
1535
+ sourceFiles,
1536
+ loaded.collection,
1537
+ rootDir,
1538
+ topoTargets,
1539
+ scope,
1540
+ collectAuthoredMcpSurfaceBindingSets(topoTargets),
1541
+ workspaceLocks.observations
1542
+ );
1543
+ const allDiagnostics: WardenDiagnostic[] = [
1544
+ ...workspaceLocks.diagnostics,
1545
+ ...lintSourceFiles(sourceFiles, context, extraSourceRules, selector),
1546
+ ];
1547
+
1548
+ if (
1549
+ topoTargets.length > 0 &&
1550
+ (selector.tier === undefined || selector.tier === 'advisory') &&
1551
+ selectorIncludesTopoRules(selector)
1552
+ ) {
1553
+ allDiagnostics.push(
1554
+ ...(await lintTopoTargets(
1555
+ topoTargets,
1556
+ extraTopoRules,
1557
+ selector,
1558
+ topoTargets.length > 1
1559
+ ))
1560
+ );
1561
+ }
1562
+
1563
+ return { diagnostics: allDiagnostics, sourceFiles };
1564
+ };
1565
+
1566
+ const topoTargetsFromOptions = (
1567
+ options: WardenRunOptions
1568
+ ): readonly WardenTopoTarget[] => {
1569
+ if (options.topos !== undefined && options.topos.length > 0) {
1570
+ return options.topos;
1571
+ }
1572
+
1573
+ return options.topo ? [{ name: options.topo.name, topo: options.topo }] : [];
1574
+ };
1575
+
1576
+ const aggregateDriftHash = (
1577
+ topoTargets: readonly WardenTopoTarget[],
1578
+ driftResults: readonly DriftResult[]
1579
+ ): string => {
1580
+ const currentHashes = new Set(
1581
+ driftResults.map((result) => result.currentHash)
1582
+ );
1583
+ const [onlyHash] = currentHashes;
1584
+ if (currentHashes.size === 1 && onlyHash !== undefined) {
1585
+ return onlyHash;
1586
+ }
1587
+
1588
+ const payload = driftResults
1589
+ .map((result, index) => {
1590
+ const target = topoTargets[index];
1591
+ return {
1592
+ currentHash: result.currentHash,
1593
+ topoName: target?.name ?? target?.topo.name ?? `topo-${String(index)}`,
1594
+ };
1595
+ })
1596
+ .toSorted((left, right) => left.topoName.localeCompare(right.topoName));
1597
+ const hasher = new Bun.CryptoHasher('sha256');
1598
+ hasher.update(JSON.stringify(payload));
1599
+ return hasher.digest('hex');
1600
+ };
1601
+
1602
+ const aggregateNamedHashes = (
1603
+ topoTargets: readonly WardenTopoTarget[],
1604
+ hashes: readonly string[]
1605
+ ): string => {
1606
+ const payload = hashes
1607
+ .map((hash, index) => {
1608
+ const target = topoTargets[index];
1609
+ return {
1610
+ hash,
1611
+ topoName: target?.name ?? target?.topo.name ?? `topo-${String(index)}`,
1612
+ };
1613
+ })
1614
+ .toSorted((left, right) => left.topoName.localeCompare(right.topoName));
1615
+ const hasher = new Bun.CryptoHasher('sha256');
1616
+ hasher.update(JSON.stringify(payload));
1617
+ return hasher.digest('hex');
1618
+ };
1619
+
1620
+ const aggregateCommittedHashes = (
1621
+ topoTargets: readonly WardenTopoTarget[],
1622
+ driftResults: readonly DriftResult[]
1623
+ ): string | null => {
1624
+ const hashes = driftResults.map((result) => result.committedHash);
1625
+ return hashes.some((hash) => hash === null)
1626
+ ? null
1627
+ : aggregateNamedHashes(topoTargets, hashes as string[]);
1628
+ };
1629
+
1630
+ const describeTopoDriftHash = (
1631
+ topoTargets: readonly WardenTopoTarget[],
1632
+ driftResults: readonly DriftResult[]
1633
+ ): string =>
1634
+ driftResults
1635
+ .map((result, index) => {
1636
+ const target = topoTargets[index];
1637
+ const topoName =
1638
+ target?.name ?? target?.topo.name ?? `topo-${String(index)}`;
1639
+ return `${topoName}=${result.committedHash ?? '<none>'}`;
1640
+ })
1641
+ .join(', ');
1642
+
1643
+ const requireCommittedLockEvidence = (
1644
+ rootDir: string,
1645
+ target: WardenTopoTarget | undefined,
1646
+ drift: DriftResult
1647
+ ): DriftResult => {
1648
+ if (
1649
+ target?.requireCommittedLock !== true ||
1650
+ drift.committedHash !== null ||
1651
+ drift.blockedReason !== undefined
1652
+ ) {
1653
+ return drift;
1654
+ }
1655
+
1656
+ const appId = target.name ?? target.topo.name;
1657
+ const lockPath = resolve(target.rootDir ?? rootDir, 'trails.lock');
1658
+ return {
1659
+ blockedReason: `Required app "${appId}" has no committed trails.lock at ${lockPath}. Regenerate with \`trails compile --app ${appId}\` from the workspace root.`,
1660
+ committedHash: null,
1661
+ currentHash: 'blocked',
1662
+ stale: true,
1663
+ };
1664
+ };
1665
+
1666
+ interface WardenDriftCheck {
1667
+ readonly drift: DriftResult;
1668
+ readonly topoDrift?: readonly WardenTopoDriftResult[] | undefined;
1669
+ }
1670
+
1671
+ const checkDriftForTopoTargets = async (
1672
+ rootDir: string,
1673
+ topoTargets: readonly WardenTopoTarget[]
1674
+ ): Promise<WardenDriftCheck> => {
1675
+ if (topoTargets.length <= 1) {
1676
+ const [target] = topoTargets;
1677
+ const drift = requireCommittedLockEvidence(
1678
+ rootDir,
1679
+ target,
1680
+ await checkDrift(
1681
+ resolve(target?.rootDir ?? rootDir),
1682
+ target?.topo,
1683
+ target === undefined ? undefined : { overlays: target.overlays }
1684
+ )
1685
+ );
1686
+ return {
1687
+ drift,
1688
+ ...(target === undefined
1689
+ ? {}
1690
+ : {
1691
+ topoDrift: [
1692
+ {
1693
+ drift,
1694
+ name: target.name ?? target.topo.name,
1695
+ rootDir: resolve(target.rootDir ?? rootDir),
1696
+ },
1697
+ ],
1698
+ }),
1699
+ };
1700
+ }
1701
+
1702
+ const targetRoots = topoTargets.map((target) =>
1703
+ resolve(target.rootDir ?? rootDir)
1704
+ );
1705
+ const uncheckedDriftResults = await Promise.all(
1706
+ topoTargets.map((target, index) =>
1707
+ checkDrift(targetRoots[index] as string, target.topo, {
1708
+ overlays: target.overlays,
1709
+ })
1710
+ )
1711
+ );
1712
+ const driftResults = uncheckedDriftResults.map((drift, index) =>
1713
+ requireCommittedLockEvidence(rootDir, topoTargets[index], drift)
1714
+ );
1715
+ const topoDrift = topoTargets.map((target, index) => ({
1716
+ drift: driftResults[index] as DriftResult,
1717
+ name: target.name ?? target.topo.name,
1718
+ rootDir: targetRoots[index] as string,
1719
+ }));
1720
+ const sharedLockRoot = new Set(targetRoots).size === 1;
1721
+ let committedHash: string | null;
1722
+ if (sharedLockRoot) {
1723
+ const committedHashes = new Set(
1724
+ driftResults.map((result) => result.committedHash)
1725
+ );
1726
+ if (committedHashes.size > 1) {
1727
+ return {
1728
+ drift: {
1729
+ blockedReason: `multi-topo drift expected one committed trails.lock hash but found conflicting hashes: ${describeTopoDriftHash(topoTargets, driftResults)}`,
1730
+ committedHash: null,
1731
+ currentHash: 'blocked',
1732
+ stale: true,
1733
+ },
1734
+ topoDrift,
1735
+ };
1736
+ }
1737
+ committedHash = driftResults[0]?.committedHash ?? null;
1738
+ } else {
1739
+ committedHash = aggregateCommittedHashes(topoTargets, driftResults);
1740
+ }
1741
+ const blockedReasons = driftResults.flatMap((result, index) => {
1742
+ if (result.blockedReason === undefined) {
1743
+ return [];
1744
+ }
1745
+ const target = topoTargets[index];
1746
+ const topoName =
1747
+ target?.name ?? target?.topo.name ?? `topo-${String(index)}`;
1748
+ return [`${topoName}: ${result.blockedReason}`];
1749
+ });
1750
+
1751
+ if (blockedReasons.length > 0) {
1752
+ return {
1753
+ drift: {
1754
+ blockedReason: blockedReasons.join('; '),
1755
+ committedHash,
1756
+ currentHash: 'blocked',
1757
+ stale: true,
1758
+ },
1759
+ topoDrift,
1760
+ };
1761
+ }
1762
+
1763
+ const currentHash = sharedLockRoot
1764
+ ? aggregateDriftHash(topoTargets, driftResults)
1765
+ : aggregateNamedHashes(
1766
+ topoTargets,
1767
+ driftResults.map((result) => result.currentHash)
1768
+ );
1769
+ const driftedOverlayNamespaces = [
1770
+ ...new Set(
1771
+ driftResults.flatMap((result) => result.driftedOverlayNamespaces ?? [])
1772
+ ),
1773
+ ].toSorted();
1774
+ return {
1775
+ drift: {
1776
+ committedHash,
1777
+ currentHash,
1778
+ ...(driftedOverlayNamespaces.length === 0
1779
+ ? {}
1780
+ : { driftedOverlayNamespaces }),
1781
+ stale: sharedLockRoot
1782
+ ? committedHash !== null && committedHash !== currentHash
1783
+ : driftResults.some((result) => result.stale),
1784
+ },
1785
+ topoDrift,
1786
+ };
1787
+ };
1788
+
1789
+ const shouldRunLint = (options: WardenRunOptions): boolean =>
1790
+ options.tier ? options.tier !== 'drift' : !options.driftOnly;
1791
+
1792
+ const adapterDiagnosticsForRun = (
1793
+ rootDir: string,
1794
+ options: WardenRunOptions
1795
+ ): readonly WardenDiagnostic[] =>
1796
+ options.adapterCheck ? runWardenAdapterChecks(rootDir) : [];
1797
+
1798
+ const shouldRunDrift = (
1799
+ options: WardenRunOptions,
1800
+ effectiveConfig: EffectiveWardenConfig
1801
+ ): boolean => {
1802
+ if (effectiveConfig.lock === 'skip') {
1803
+ return false;
1804
+ }
1805
+
1806
+ if (options.tier) {
1807
+ return options.tier === 'drift';
1808
+ }
1809
+
1810
+ if (options.lintOnly) {
1811
+ return false;
1812
+ }
1813
+
1814
+ return options.driftOnly || effectiveConfig.depth === 'all';
1815
+ };
1816
+
1817
+ const reportPassed = ({
1818
+ drift,
1819
+ errorCount,
1820
+ failOn,
1821
+ warnCount,
1822
+ }: {
1823
+ readonly drift: DriftResult | null;
1824
+ readonly errorCount: number;
1825
+ readonly failOn: WardenFailOn;
1826
+ readonly warnCount: number;
1827
+ }): boolean =>
1828
+ errorCount === 0 &&
1829
+ (failOn === 'error' || warnCount === 0) &&
1830
+ !(drift?.stale ?? false) &&
1831
+ drift?.blockedReason === undefined;
1832
+
1833
+ const buildCliConfigLayer = (options: WardenRunOptions): WardenConfigLayer => ({
1834
+ ...(options.apps ? { apps: [...options.apps] } : {}),
1835
+ ...(options.depth ? { depth: options.depth } : {}),
1836
+ ...(options.drafts ? { drafts: options.drafts } : {}),
1837
+ ...(options.failOn ? { failOn: options.failOn } : {}),
1838
+ ...(options.format ? { format: options.format } : {}),
1839
+ ...(options.scope ? { scope: options.scope } : {}),
1840
+ ...(options.lock ? { lock: options.lock } : {}),
1841
+ ...(options.noLockMutation === undefined
1842
+ ? {}
1843
+ : { noLockMutation: options.noLockMutation }),
1844
+ });
1845
+
1846
+ const fixSummary = (application: WardenFixApplication): WardenFixSummary => ({
1847
+ applied: application.applied,
1848
+ filesChanged: application.filesChanged,
1849
+ skipped: application.skipped,
1850
+ });
1851
+
1852
+ const filterAppliedFixDiagnostics = (
1853
+ diagnostics: readonly WardenDiagnostic[],
1854
+ appliedDiagnostics: readonly WardenDiagnostic[]
1855
+ ): readonly WardenDiagnostic[] => {
1856
+ if (appliedDiagnostics.length === 0) {
1857
+ return diagnostics;
1858
+ }
1859
+ const applied = new Set(appliedDiagnostics);
1860
+ return diagnostics.filter((diagnostic) => !applied.has(diagnostic));
1861
+ };
1862
+
1863
+ const blockedDriftAfterSourceFixes = (): DriftResult => ({
1864
+ blockedReason:
1865
+ 'Source fixes were applied; rerun Warden to refresh drift evidence.',
1866
+ committedHash: null,
1867
+ currentHash: 'blocked',
1868
+ stale: true,
1869
+ });
1870
+
1871
+ /**
1872
+ * Apply every safe source fix among a run's diagnostics, writing patched files.
1873
+ *
1874
+ * Diagnostics with a safe, edit-bearing fix are grouped by file; each file is
1875
+ * re-read so the rule's recorded offsets stay valid, patched via
1876
+ * {@link applySafeFixesToSource}, and written back only when its source
1877
+ * actually changed. Diagnostics that carry fix metadata but are not safe with
1878
+ * edits (review-required or edit-less) are counted as skipped and left reported
1879
+ * for a human or downstream regrade to resolve. Diagnostics without fix
1880
+ * metadata — including topo diagnostics, which carry no source span — are
1881
+ * neither applied nor counted in `skipped`.
1882
+ */
1883
+ export const applySafeFixesToFiles = async (
1884
+ diagnostics: readonly WardenDiagnostic[],
1885
+ options: {
1886
+ readonly allowedFilePaths?: ReadonlySet<string> | readonly string[];
1887
+ readonly rootDir: string;
1888
+ }
1889
+ ): Promise<WardenFixApplication> => {
1890
+ const rootDir = resolve(options.rootDir);
1891
+ const allowedFilePaths =
1892
+ options.allowedFilePaths === undefined
1893
+ ? undefined
1894
+ : new Set(
1895
+ [...options.allowedFilePaths].map((filePath) => resolve(filePath))
1896
+ );
1897
+ const fixableByFile = new Map<string, WardenDiagnostic[]>();
1898
+ let skipped = 0;
1899
+ for (const diagnostic of diagnostics) {
1900
+ if (diagnostic.fix === undefined) {
1901
+ continue;
1902
+ }
1903
+ if (hasSafeFixEdits(diagnostic)) {
1904
+ const filePath = resolve(diagnostic.filePath);
1905
+ const rootRelativePath = relative(rootDir, filePath);
1906
+ const insideRoot =
1907
+ rootRelativePath.length === 0 ||
1908
+ (!rootRelativePath.startsWith('..') && !isAbsolute(rootRelativePath));
1909
+ if (
1910
+ !insideRoot ||
1911
+ (allowedFilePaths !== undefined && !allowedFilePaths.has(filePath))
1912
+ ) {
1913
+ skipped += 1;
1914
+ continue;
1915
+ }
1916
+ const bucket = fixableByFile.get(filePath) ?? [];
1917
+ bucket.push(diagnostic);
1918
+ fixableByFile.set(filePath, bucket);
1919
+ } else {
1920
+ skipped += 1;
1921
+ }
1922
+ }
1923
+
1924
+ let applied = 0;
1925
+ const appliedDiagnostics: WardenDiagnostic[] = [];
1926
+ let filesChanged = 0;
1927
+ for (const [filePath, group] of fixableByFile) {
1928
+ const source = await Bun.file(filePath).text();
1929
+ const result = applySafeFixesToSource(source, group);
1930
+ applied += result.applied.length;
1931
+ appliedDiagnostics.push(...result.applied);
1932
+ if (result.changed) {
1933
+ await Bun.write(filePath, result.patched);
1934
+ filesChanged += 1;
1935
+ }
1936
+ }
1937
+
1938
+ return { applied, appliedDiagnostics, filesChanged, skipped };
1939
+ };
1940
+
1941
+ /**
1942
+ * Run all warden checks and return a structured report.
1943
+ */
1944
+ export const runWarden = async (
1945
+ options: WardenRunOptions = {}
1946
+ ): Promise<WardenReport> => {
1947
+ const { rootDir } = resolveTrailsProjectRoot({
1948
+ explicitRootDir: options.rootDir,
1949
+ startDir: process.cwd(),
1950
+ });
1951
+ const { diagnostics: configDiagnostics, effectiveConfig } =
1952
+ resolveWardenConfig({
1953
+ cli: buildCliConfigLayer(options),
1954
+ config: options.config,
1955
+ env: options.env,
1956
+ });
1957
+ const optionDiagnostics =
1958
+ !options.tier && options.lintOnly && options.driftOnly
1959
+ ? [
1960
+ createOptionsDiagnostic(
1961
+ 'lintOnly and driftOnly cannot both be true. Use tier to select a single Warden mode.'
1962
+ ),
1963
+ ]
1964
+ : [];
1965
+ const topoTargets = topoTargetsFromOptions(options);
1966
+ const selector = {
1967
+ depth: options.tier ? undefined : effectiveConfig.depth,
1968
+ tier: options.tier,
1969
+ } satisfies WardenRuleSelector;
1970
+ const runLint = shouldRunLint(options);
1971
+ const runDrift = shouldRunDrift(options, effectiveConfig);
1972
+ const projectRules = await loadProjectRulesForRun(rootDir, options, runLint);
1973
+ const lintResult = runLint
1974
+ ? await lintFiles(
1975
+ rootDir,
1976
+ effectiveConfig.drafts,
1977
+ effectiveConfig.scope,
1978
+ topoTargets,
1979
+ [...projectRules.topoRules, ...(options.extraTopoRules ?? [])],
1980
+ [...projectRules.sourceRules, ...(options.extraSourceRules ?? [])],
1981
+ selector,
1982
+ options.configPath
1983
+ )
1984
+ : { diagnostics: [], sourceFiles: [] };
1985
+ const adapterDiagnostics = adapterDiagnosticsForRun(rootDir, options);
1986
+
1987
+ const rawDiagnostics = [
1988
+ ...configDiagnostics,
1989
+ ...projectRules.diagnostics,
1990
+ ...optionDiagnostics,
1991
+ ...lintResult.diagnostics,
1992
+ ...adapterDiagnostics,
1993
+ ];
1994
+ const allDiagnostics = rawDiagnostics.map(withDiagnosticGuidance);
1995
+ const fixApplication = options.fix
1996
+ ? await applySafeFixesToFiles(allDiagnostics, {
1997
+ allowedFilePaths: lintResult.sourceFiles.map(
1998
+ (sourceFile) => sourceFile.filePath
1999
+ ),
2000
+ rootDir,
2001
+ })
2002
+ : undefined;
2003
+ const reportDiagnostics = filterAppliedFixDiagnostics(
2004
+ allDiagnostics,
2005
+ fixApplication?.appliedDiagnostics ?? []
2006
+ );
2007
+ let drift: DriftResult | null = null;
2008
+ let topoDrift: readonly WardenTopoDriftResult[] | undefined;
2009
+ if (runDrift) {
2010
+ if (fixApplication !== undefined && fixApplication.filesChanged > 0) {
2011
+ drift = blockedDriftAfterSourceFixes();
2012
+ } else {
2013
+ const checked = await checkDriftForTopoTargets(rootDir, topoTargets);
2014
+ ({ drift } = checked);
2015
+ ({ topoDrift } = checked);
2016
+ }
2017
+ }
2018
+
2019
+ const errorCount = reportDiagnostics.filter(
2020
+ (d) => d.severity === 'error'
2021
+ ).length;
2022
+ const warnCount = reportDiagnostics.filter(
2023
+ (d) => d.severity === 'warn'
2024
+ ).length;
2025
+ const topoNames =
2026
+ topoTargets.length > 0
2027
+ ? topoTargets.map((target) => target.name ?? target.topo.name)
2028
+ : undefined;
2029
+
2030
+ return {
2031
+ diagnostics: reportDiagnostics,
2032
+ drift,
2033
+ effectiveConfig,
2034
+ errorCount,
2035
+ ...(fixApplication === undefined
2036
+ ? {}
2037
+ : { fixes: fixSummary(fixApplication) }),
2038
+ passed: reportPassed({
2039
+ drift,
2040
+ errorCount,
2041
+ failOn: effectiveConfig.failOn,
2042
+ warnCount,
2043
+ }),
2044
+ ...(topoNames === undefined ? {} : { topoNames }),
2045
+ ...(topoDrift === undefined ? {} : { topoDrift }),
2046
+ warnCount,
2047
+ };
2048
+ };
2049
+
2050
+ const formatPlainGuidanceLink = (link: WardenGuidanceLink): string => {
2051
+ const target = link.path ?? link.url;
2052
+ if (target === undefined || target === link.label) {
2053
+ return link.label;
2054
+ }
2055
+ return `${link.label} (${target})`;
2056
+ };
2057
+
2058
+ /**
2059
+ * Format the lint section of the report.
2060
+ */
2061
+ const formatLintSection = (report: WardenReport): string[] => {
2062
+ if (report.diagnostics.length === 0) {
2063
+ return ['Lint: clean'];
2064
+ }
2065
+
2066
+ const lines = [
2067
+ `Lint: ${report.errorCount} errors, ${report.warnCount} warnings`,
2068
+ ];
2069
+
2070
+ for (const d of report.diagnostics) {
2071
+ const prefix = d.severity === 'error' ? 'ERROR' : 'WARN';
2072
+ lines.push(
2073
+ ` ${d.filePath}:${String(d.line)} [${prefix}] ${d.rule} ${d.message}`
2074
+ );
2075
+ if (d.guidance !== undefined) {
2076
+ lines.push(` Next: ${d.guidance.summary}`);
2077
+ for (const [index, step] of (d.guidance.steps ?? []).entries()) {
2078
+ lines.push(` ${String(index + 1)}. ${step}`);
2079
+ }
2080
+ if (d.guidance.commands !== undefined) {
2081
+ lines.push(
2082
+ ` Commands: ${d.guidance.commands.map((cmd) => `\`${cmd}\``).join(', ')}`
2083
+ );
2084
+ }
2085
+ if (d.guidance.docs !== undefined) {
2086
+ lines.push(
2087
+ ` Docs: ${d.guidance.docs.map(formatPlainGuidanceLink).join(', ')}`
2088
+ );
2089
+ }
2090
+ if (d.guidance.relatedRules !== undefined) {
2091
+ lines.push(` Related: ${d.guidance.relatedRules.join(', ')}`);
2092
+ }
2093
+ }
2094
+ }
2095
+
2096
+ return lines;
2097
+ };
2098
+
2099
+ /**
2100
+ * Format the drift section of the report.
2101
+ */
2102
+ const formatDriftSection = (drift: DriftResult | null): string[] => {
2103
+ if (drift === null) {
2104
+ return [];
2105
+ }
2106
+ if (drift.blockedReason !== undefined) {
2107
+ return [`Drift: blocked (${drift.blockedReason})`, ''];
2108
+ }
2109
+ const label = drift.stale
2110
+ ? `Drift: ${staleDriftMessage(drift)}`
2111
+ : 'Drift: clean';
2112
+ return [label, ''];
2113
+ };
2114
+
2115
+ /**
2116
+ * Format the result line.
2117
+ */
2118
+ const formatResultLine = (report: WardenReport): string => {
2119
+ if (report.passed) {
2120
+ return 'Result: PASS';
2121
+ }
2122
+ const parts: string[] = [];
2123
+ if (report.errorCount > 0) {
2124
+ parts.push(`${report.errorCount} errors`);
2125
+ }
2126
+ if (report.warnCount > 0 && report.effectiveConfig?.failOn === 'warning') {
2127
+ parts.push(`${report.warnCount} warnings`);
2128
+ }
2129
+ if (report.drift?.blockedReason !== undefined) {
2130
+ parts.push('established exports blocked');
2131
+ } else if (report.drift?.stale) {
2132
+ parts.push('drift detected');
2133
+ }
2134
+ return `Result: FAIL (${parts.join(', ')})`;
2135
+ };
2136
+
2137
+ /**
2138
+ * Format a warden report as a human-readable string.
2139
+ */
2140
+ export const formatWardenReport = (report: WardenReport): string => {
2141
+ const lintLines = formatLintSection(report);
2142
+ const driftLines = formatDriftSection(report.drift);
2143
+
2144
+ if (lintLines.length === 0 && driftLines.length === 0) {
2145
+ return ['Warden Report', '=============', '', 'No checks were run.'].join(
2146
+ '\n'
2147
+ );
2148
+ }
2149
+
2150
+ return [
2151
+ 'Warden Report',
2152
+ '=============',
2153
+ '',
2154
+ ...lintLines,
2155
+ '',
2156
+ ...driftLines,
2157
+ formatResultLine(report),
2158
+ ].join('\n');
2159
+ };