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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +837 -13
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +27 -6
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +1570 -105
  7. package/src/command.ts +986 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +233 -23
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +78 -13
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +247 -15
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/resolve.ts +531 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/captured-kernel.ts +375 -0
  20. package/src/rules/circular-refs.ts +150 -0
  21. package/src/rules/cli-command-route-coherence.ts +177 -0
  22. package/src/rules/composes-declarations.ts +839 -0
  23. package/src/rules/context-no-surface-types.ts +79 -15
  24. package/src/rules/dead-internal-trail.ts +161 -0
  25. package/src/rules/dead-public-trail.ts +258 -0
  26. package/src/rules/draft-file-marking.ts +155 -0
  27. package/src/rules/draft-visible-debt.ts +82 -0
  28. package/src/rules/duplicate-exported-symbol.ts +211 -0
  29. package/src/rules/duplicate-public-contract.ts +137 -0
  30. package/src/rules/entity-exists.ts +254 -0
  31. package/src/rules/entity-ids.ts +15 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +740 -0
  35. package/src/rules/governed-symbol-residue.ts +438 -0
  36. package/src/rules/implementation-returns-result.ts +1409 -166
  37. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  38. package/src/rules/incomplete-crud.ts +583 -0
  39. package/src/rules/index.ts +277 -10
  40. package/src/rules/intent-propagation.ts +125 -0
  41. package/src/rules/layer-field-name-drift.ts +102 -0
  42. package/src/rules/library-projection-coherence.ts +100 -0
  43. package/src/rules/metadata.ts +871 -0
  44. package/src/rules/missing-reconcile.ts +97 -0
  45. package/src/rules/missing-visibility.ts +111 -0
  46. package/src/rules/no-destructured-compose.ts +196 -0
  47. package/src/rules/no-dev-permit-in-source.ts +99 -0
  48. package/src/rules/no-direct-implementation-call.ts +12 -7
  49. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  50. package/src/rules/no-legacy-layer-imports.ts +211 -0
  51. package/src/rules/no-native-error-result.ts +118 -0
  52. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  53. package/src/rules/no-retired-cross-vocabulary.ts +204 -0
  54. package/src/rules/no-sync-result-assumption.ts +1141 -98
  55. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  56. package/src/rules/no-throw-in-implementation.ts +15 -8
  57. package/src/rules/no-top-level-surface.ts +371 -0
  58. package/src/rules/on-references-exist.ts +194 -0
  59. package/src/rules/orphaned-signal.ts +149 -0
  60. package/src/rules/owner-projection-parity.ts +143 -0
  61. package/src/rules/permit-governance.ts +25 -0
  62. package/src/rules/public-export-example-coverage.ts +573 -0
  63. package/src/rules/public-internal-deep-imports.ts +456 -0
  64. package/src/rules/public-output-schema.ts +29 -0
  65. package/src/rules/public-union-output-discriminants.ts +150 -0
  66. package/src/rules/read-intent-fires.ts +188 -0
  67. package/src/rules/reference-exists.ts +97 -0
  68. package/src/rules/registry-names.ts +167 -0
  69. package/src/rules/resolved-import-boundary.ts +146 -0
  70. package/src/rules/resource-declarations.ts +697 -0
  71. package/src/rules/resource-exists.ts +181 -0
  72. package/src/rules/resource-id-grammar.ts +65 -0
  73. package/src/rules/resource-mock-coverage.ts +115 -0
  74. package/src/rules/retired-vocabulary.ts +633 -0
  75. package/src/rules/scan.ts +38 -25
  76. package/src/rules/scheduled-destroy-intent.ts +44 -0
  77. package/src/rules/signal-graph-coaching.ts +220 -0
  78. package/src/rules/source/composition.ts +165 -0
  79. package/src/rules/source/drafts.ts +164 -0
  80. package/src/rules/source/entities.ts +618 -0
  81. package/src/rules/source/pragmas.ts +45 -0
  82. package/src/rules/source/resources.ts +64 -0
  83. package/src/rules/source/signals.ts +397 -0
  84. package/src/rules/source/stores.ts +310 -0
  85. package/src/rules/specs.ts +9 -5
  86. package/src/rules/static-resource-accessor-preference.ts +653 -0
  87. package/src/rules/surface-overlay-coherence.ts +262 -0
  88. package/src/rules/surface-trailhead-coherence.ts +366 -0
  89. package/src/rules/trail-fork-coaching.ts +625 -0
  90. package/src/rules/trail-versioning-source.ts +1076 -0
  91. package/src/rules/trail-versioning-topo.ts +172 -0
  92. package/src/rules/trailhead-override-divergence.ts +356 -0
  93. package/src/rules/types.ts +354 -8
  94. package/src/rules/unmaterialized-activation-source.ts +85 -0
  95. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  96. package/src/rules/valid-describe-refs.ts +162 -32
  97. package/src/rules/valid-detour-contract.ts +78 -0
  98. package/src/rules/warden-export-symmetry.ts +540 -0
  99. package/src/rules/warden-rules-use-ast.ts +1109 -0
  100. package/src/rules/webhook-route-collision.ts +306 -0
  101. package/src/trails/activation-orphan.trail.ts +84 -0
  102. package/src/trails/captured-kernel.trail.ts +108 -0
  103. package/src/trails/circular-refs.trail.ts +29 -0
  104. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  105. package/src/trails/composes-declarations.trail.ts +22 -0
  106. package/src/trails/context-no-surface-types.trail.ts +21 -0
  107. package/src/trails/dead-internal-trail.trail.ts +26 -0
  108. package/src/trails/dead-public-trail.trail.ts +31 -0
  109. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  110. package/src/trails/draft-file-marking.trail.ts +16 -0
  111. package/src/trails/draft-visible-debt.trail.ts +16 -0
  112. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  113. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  114. package/src/trails/entity-exists.trail.ts +21 -0
  115. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  116. package/src/trails/example-valid.trail.ts +25 -0
  117. package/src/trails/fires-declarations.trail.ts +23 -0
  118. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  119. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  120. package/src/trails/implementation-returns-result.trail.ts +20 -0
  121. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  122. package/src/trails/incomplete-crud.trail.ts +39 -0
  123. package/src/trails/index.ts +89 -0
  124. package/src/trails/intent-propagation.trail.ts +30 -0
  125. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  126. package/src/trails/library-projection-coherence.trail.ts +43 -0
  127. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  128. package/src/trails/missing-reconcile.trail.ts +33 -0
  129. package/src/trails/missing-visibility.trail.ts +22 -0
  130. package/src/trails/no-destructured-compose.trail.ts +44 -0
  131. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  132. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  133. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  134. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  135. package/src/trails/no-native-error-result.trail.ts +18 -0
  136. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  137. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  138. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  139. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  140. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  141. package/src/trails/no-top-level-surface.trail.ts +43 -0
  142. package/src/trails/on-references-exist.trail.ts +21 -0
  143. package/src/trails/orphaned-signal.trail.ts +36 -0
  144. package/src/trails/owner-projection-parity.trail.ts +26 -0
  145. package/src/trails/pending-force.trail.ts +21 -0
  146. package/src/trails/permit-governance.trail.ts +51 -0
  147. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  148. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  149. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  150. package/src/trails/public-output-schema.trail.ts +55 -0
  151. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  152. package/src/trails/read-intent-fires.trail.ts +20 -0
  153. package/src/trails/reference-exists.trail.ts +25 -0
  154. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  155. package/src/trails/resource-declarations.trail.ts +25 -0
  156. package/src/trails/resource-exists.trail.ts +27 -0
  157. package/src/trails/resource-id-grammar.trail.ts +39 -0
  158. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  159. package/src/trails/run.ts +160 -0
  160. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  161. package/src/trails/schema.ts +237 -0
  162. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  163. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  164. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  165. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  166. package/src/trails/topo.ts +6 -0
  167. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  168. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  169. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  170. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  171. package/src/trails/valid-describe-refs.trail.ts +18 -0
  172. package/src/trails/valid-detour-contract.trail.ts +71 -0
  173. package/src/trails/version-gap.trail.ts +35 -0
  174. package/src/trails/version-pinned-compose.trail.ts +23 -0
  175. package/src/trails/version-without-examples.trail.ts +38 -0
  176. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  177. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  178. package/src/trails/webhook-route-collision.trail.ts +50 -0
  179. package/src/trails/wrap-rule.ts +224 -0
  180. package/src/workspaces.ts +199 -0
  181. package/.turbo/turbo-build.log +0 -1
  182. package/.turbo/turbo-lint.log +0 -3
  183. package/.turbo/turbo-typecheck.log +0 -1
  184. package/dist/cli.d.ts +0 -46
  185. package/dist/cli.d.ts.map +0 -1
  186. package/dist/cli.js +0 -218
  187. package/dist/cli.js.map +0 -1
  188. package/dist/drift.d.ts +0 -26
  189. package/dist/drift.d.ts.map +0 -1
  190. package/dist/drift.js +0 -27
  191. package/dist/drift.js.map +0 -1
  192. package/dist/formatters.d.ts +0 -29
  193. package/dist/formatters.d.ts.map +0 -1
  194. package/dist/formatters.js +0 -87
  195. package/dist/formatters.js.map +0 -1
  196. package/dist/index.d.ts +0 -26
  197. package/dist/index.d.ts.map +0 -1
  198. package/dist/index.js +0 -26
  199. package/dist/index.js.map +0 -1
  200. package/dist/rules/ast.d.ts +0 -41
  201. package/dist/rules/ast.d.ts.map +0 -1
  202. package/dist/rules/ast.js +0 -161
  203. package/dist/rules/ast.js.map +0 -1
  204. package/dist/rules/context-no-surface-types.d.ts +0 -12
  205. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  206. package/dist/rules/context-no-surface-types.js +0 -96
  207. package/dist/rules/context-no-surface-types.js.map +0 -1
  208. package/dist/rules/implementation-returns-result.d.ts +0 -13
  209. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  210. package/dist/rules/implementation-returns-result.js +0 -277
  211. package/dist/rules/implementation-returns-result.js.map +0 -1
  212. package/dist/rules/index.d.ts +0 -15
  213. package/dist/rules/index.d.ts.map +0 -1
  214. package/dist/rules/index.js +0 -34
  215. package/dist/rules/index.js.map +0 -1
  216. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  217. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  218. package/dist/rules/no-direct-impl-in-route.js +0 -47
  219. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  220. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  221. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  222. package/dist/rules/no-direct-implementation-call.js +0 -39
  223. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  224. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  225. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  226. package/dist/rules/no-sync-result-assumption.js +0 -98
  227. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  228. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  229. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  230. package/dist/rules/no-throw-in-detour-target.js +0 -87
  231. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  232. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  233. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  234. package/dist/rules/no-throw-in-implementation.js +0 -34
  235. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  236. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  237. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  238. package/dist/rules/prefer-schema-inference.js +0 -86
  239. package/dist/rules/prefer-schema-inference.js.map +0 -1
  240. package/dist/rules/scan.d.ts +0 -8
  241. package/dist/rules/scan.d.ts.map +0 -1
  242. package/dist/rules/scan.js +0 -32
  243. package/dist/rules/scan.js.map +0 -1
  244. package/dist/rules/specs.d.ts +0 -29
  245. package/dist/rules/specs.d.ts.map +0 -1
  246. package/dist/rules/specs.js +0 -192
  247. package/dist/rules/specs.js.map +0 -1
  248. package/dist/rules/structure.d.ts +0 -13
  249. package/dist/rules/structure.d.ts.map +0 -1
  250. package/dist/rules/structure.js +0 -142
  251. package/dist/rules/structure.js.map +0 -1
  252. package/dist/rules/types.d.ts +0 -52
  253. package/dist/rules/types.d.ts.map +0 -1
  254. package/dist/rules/types.js +0 -2
  255. package/dist/rules/types.js.map +0 -1
  256. package/dist/rules/valid-describe-refs.d.ts +0 -7
  257. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  258. package/dist/rules/valid-describe-refs.js +0 -51
  259. package/dist/rules/valid-describe-refs.js.map +0 -1
  260. package/dist/rules/valid-detour-refs.d.ts +0 -6
  261. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  262. package/dist/rules/valid-detour-refs.js +0 -116
  263. package/dist/rules/valid-detour-refs.js.map +0 -1
  264. package/src/__tests__/cli.test.ts +0 -198
  265. package/src/__tests__/drift.test.ts +0 -74
  266. package/src/__tests__/formatters.test.ts +0 -157
  267. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  268. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  269. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  270. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  271. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  272. package/src/__tests__/rules.test.ts +0 -227
  273. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  274. package/src/rules/ast.ts +0 -213
  275. package/src/rules/no-direct-impl-in-route.ts +0 -81
  276. package/src/rules/no-throw-in-detour-target.ts +0 -150
  277. package/src/rules/valid-detour-refs.ts +0 -187
  278. package/tsconfig.json +0 -9
  279. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,172 @@
1
+ import { deriveTopoGraph } from '@ontrails/topography';
2
+ import type {
3
+ TopoGraph,
4
+ TopoGraphEntry,
5
+ TopoGraphForceEntry,
6
+ TopoGraphVersionEntry,
7
+ } from '@ontrails/topography';
8
+ import type { Topo } from '@ontrails/core';
9
+
10
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
11
+
12
+ const TOPO_FILE = '<topo>';
13
+
14
+ const isArchived = (entry: TopoGraphVersionEntry): boolean =>
15
+ entry.status?.state === 'archived';
16
+
17
+ const isDeprecatedWithoutGuidance = (entry: TopoGraphVersionEntry): boolean =>
18
+ entry.status?.state === 'deprecated' &&
19
+ entry.status.successor === undefined &&
20
+ entry.status.note === undefined &&
21
+ (entry.status.migration === undefined || entry.status.migration.length === 0);
22
+
23
+ const versionNumbersFor = (entry: TopoGraphEntry): readonly number[] => [
24
+ ...(entry.version === undefined ? [] : [entry.version]),
25
+ ...Object.keys(entry.versions ?? {}).map(Number),
26
+ ];
27
+
28
+ const versionEntryName = (trailId: string, version: string): string =>
29
+ `${trailId}@${version}`;
30
+
31
+ const topoDiagnostic = (
32
+ rule: string,
33
+ severity: WardenDiagnostic['severity'],
34
+ message: string
35
+ ): WardenDiagnostic => ({
36
+ filePath: TOPO_FILE,
37
+ line: 1,
38
+ message,
39
+ rule,
40
+ severity,
41
+ });
42
+
43
+ const trailEntries = (graph: TopoGraph): readonly TopoGraphEntry[] =>
44
+ graph.entries.filter((entry) => entry.kind === 'trail');
45
+
46
+ const graphFor = (
47
+ topo: Topo,
48
+ context: Parameters<TopoAwareWardenRule['checkTopo']>[1]
49
+ ): TopoGraph => context?.graph ?? deriveTopoGraph(topo);
50
+
51
+ const forceKey = (force: TopoGraphForceEntry): string =>
52
+ JSON.stringify([
53
+ force.kind,
54
+ force.id,
55
+ force.change,
56
+ force.detail,
57
+ force.reason,
58
+ force.severity,
59
+ force.source,
60
+ ]);
61
+
62
+ const pendingForceDiagnostic = (force: TopoGraphForceEntry): WardenDiagnostic =>
63
+ topoDiagnostic(
64
+ 'pending-force',
65
+ 'warn',
66
+ `Trail "${force.id}" has a pending forced topo break (${force.change}: ${force.detail}). Resolve or document the force event before release.`
67
+ );
68
+
69
+ export const deprecationWithoutGuidance: TopoAwareWardenRule = {
70
+ checkTopo(topo, context) {
71
+ return trailEntries(graphFor(topo, context)).flatMap((entry) =>
72
+ Object.entries(entry.versions ?? {}).flatMap(([version, historical]) =>
73
+ isDeprecatedWithoutGuidance(historical)
74
+ ? [
75
+ topoDiagnostic(
76
+ 'deprecation-without-guidance',
77
+ 'error',
78
+ `Trail "${versionEntryName(entry.id, version)}" is deprecated without successor, migration, or note guidance. Add at least one guidance field to the version status.`
79
+ ),
80
+ ]
81
+ : []
82
+ )
83
+ );
84
+ },
85
+ description:
86
+ 'Require deprecated trail version entries to carry successor, migration, or note guidance.',
87
+ name: 'deprecation-without-guidance',
88
+ severity: 'error',
89
+ };
90
+
91
+ export const versionGap: TopoAwareWardenRule = {
92
+ checkTopo(topo, context) {
93
+ const diagnostics: WardenDiagnostic[] = [];
94
+ for (const entry of trailEntries(graphFor(topo, context))) {
95
+ if (entry.version === undefined) {
96
+ continue;
97
+ }
98
+ const historicalVersions = new Set(
99
+ Object.keys(entry.versions ?? {}).map(Number)
100
+ );
101
+ if (historicalVersions.has(entry.version)) {
102
+ diagnostics.push(
103
+ topoDiagnostic(
104
+ 'version-gap',
105
+ 'error',
106
+ `Trail "${entry.id}" declares current version ${entry.version} both as current and as a historical entry. Remove the duplicate historical entry.`
107
+ )
108
+ );
109
+ }
110
+
111
+ const versions = new Set(versionNumbersFor(entry));
112
+ for (let version = 1; version <= entry.version; version += 1) {
113
+ if (!versions.has(version)) {
114
+ diagnostics.push(
115
+ topoDiagnostic(
116
+ 'version-gap',
117
+ 'error',
118
+ `Trail "${entry.id}" has a gap before current version ${entry.version}: missing version ${version}. Historical version coverage must be contiguous, including archived entries.`
119
+ )
120
+ );
121
+ }
122
+ }
123
+ }
124
+ return diagnostics;
125
+ },
126
+ description:
127
+ 'Require trail version coverage to stay contiguous from v1 through the current version.',
128
+ name: 'version-gap',
129
+ severity: 'error',
130
+ };
131
+
132
+ export const versionWithoutExamples: TopoAwareWardenRule = {
133
+ checkTopo(topo, context) {
134
+ return trailEntries(graphFor(topo, context)).flatMap((entry) =>
135
+ Object.entries(entry.versions ?? {}).flatMap(([version, historical]) =>
136
+ !isArchived(historical) && (historical.exampleCount ?? 0) === 0
137
+ ? [
138
+ topoDiagnostic(
139
+ 'version-without-examples',
140
+ 'warn',
141
+ `Trail "${versionEntryName(entry.id, version)}" is a live historical version without examples. Add version-entry examples or archive the entry if it no longer participates in runtime negotiation.`
142
+ ),
143
+ ]
144
+ : []
145
+ )
146
+ );
147
+ },
148
+ description:
149
+ 'Warn when live historical trail version entries do not include examples.',
150
+ name: 'version-without-examples',
151
+ severity: 'warn',
152
+ };
153
+
154
+ export const pendingForce: TopoAwareWardenRule = {
155
+ checkTopo(topo, context) {
156
+ const graph = graphFor(topo, context);
157
+ const diagnostics = new Map<string, WardenDiagnostic>();
158
+ for (const entry of trailEntries(graph)) {
159
+ for (const force of entry.forces ?? []) {
160
+ diagnostics.set(forceKey(force), pendingForceDiagnostic(force));
161
+ }
162
+ }
163
+ for (const force of graph.forces ?? []) {
164
+ diagnostics.set(forceKey(force), pendingForceDiagnostic(force));
165
+ }
166
+ return [...diagnostics.values()];
167
+ },
168
+ description:
169
+ 'Warn when graph-only force audit events remain attached to trail entries or the topo graph.',
170
+ name: 'pending-force',
171
+ severity: 'warn',
172
+ };
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Warn when a call-site MCP trailhead map diverges from the app-authored
3
+ * `surfaces` overlay's `mcp` list bindings.
4
+ *
5
+ * The overlay is the authored, lockable default; a call-site trailhead map
6
+ * is a supported override-in-context that wins at runtime. Divergence between
7
+ * the two is legal but must be visible: an agent reading the committed lock
8
+ * would otherwise trust grouped entries the running surface does not project.
9
+ *
10
+ * Rule-kind note: no Warden rule kind currently sees both the serialized
11
+ * graph overlays and source ASTs directly. This rule is the closest honest
12
+ * shape — a project-aware source rule whose `ProjectContext` carries
13
+ * per-app authored `mcp` binding sets resolved from the run's topo targets
14
+ * (graph overlays when available, else app-module overlay registrations).
15
+ * A call-site map is attributed to an app only when one of its literal
16
+ * member selectors matches a trail id in that app's topo, so one app's
17
+ * authored bindings never flag another app's call-site map. Without
18
+ * context (`check` without context, or a run with no topo targets) the
19
+ * rule stays silent rather than guessing.
20
+ */
21
+
22
+ import { classifySurfaceBinding, matchesTrailPattern } from '@ontrails/core';
23
+ import type { SurfaceBindings } from '@ontrails/core';
24
+
25
+ import {
26
+ extractStringOrTemplateLiteral,
27
+ findConfigProperty,
28
+ getNodeArgument,
29
+ getNodeElements,
30
+ getNodeExpression,
31
+ getNodeId,
32
+ getNodeInit,
33
+ getNodeKey,
34
+ getNodeName,
35
+ getNodeProperties,
36
+ getNodeTypeAnnotation,
37
+ getNodeValueNode,
38
+ getPropertyName,
39
+ offsetToLine,
40
+ parse,
41
+ walk,
42
+ } from '@ontrails/source';
43
+ import type { AstNode } from '@ontrails/source';
44
+ import type {
45
+ AuthoredMcpSurfaceBindingSet,
46
+ ProjectAwareWardenRule,
47
+ ProjectContext,
48
+ WardenDiagnostic,
49
+ } from './types.js';
50
+
51
+ const RULE_NAME = 'trailhead-override-divergence';
52
+
53
+ const unwrapExpression = (node: AstNode | undefined): AstNode | undefined => {
54
+ let current = node;
55
+ while (
56
+ current?.type === 'TSAsExpression' ||
57
+ current?.type === 'TSSatisfiesExpression'
58
+ ) {
59
+ current = getNodeExpression(current) ?? getNodeArgument(current);
60
+ }
61
+ return current;
62
+ };
63
+
64
+ const objectProperties = (node: AstNode): readonly AstNode[] =>
65
+ node.type === 'ObjectExpression' ? (getNodeProperties(node) ?? []) : [];
66
+
67
+ const propertyValue = (property: AstNode): AstNode | undefined =>
68
+ property.type === 'Property' ? getNodeValueNode(property) : undefined;
69
+
70
+ const isTrailheadDefinition = (node: AstNode): boolean =>
71
+ node.type === 'ObjectExpression' &&
72
+ findConfigProperty(node, 'trails') !== null;
73
+
74
+ const isTrailheadMapCandidate = (node: AstNode): boolean =>
75
+ objectProperties(node).some((property) => {
76
+ const value = unwrapExpression(propertyValue(property));
77
+ return value !== undefined && isTrailheadDefinition(value);
78
+ });
79
+
80
+ const isTrailheadMapBindingName = (name: string | null): boolean =>
81
+ name !== null &&
82
+ (name === 'trailheads' ||
83
+ name.endsWith('Trailheads') ||
84
+ name.endsWith('TrailheadMap'));
85
+
86
+ const hasTrailheadMapTypeAnnotation = (
87
+ sourceCode: string,
88
+ node: AstNode
89
+ ): boolean => {
90
+ const typeAnnotation = getNodeTypeAnnotation(node);
91
+ return (
92
+ typeAnnotation !== undefined &&
93
+ /\b(?:McpSurfaceTrailheadMap|TrailheadMap)\b/.test(
94
+ sourceCode.slice(typeAnnotation.start, typeAnnotation.end)
95
+ )
96
+ );
97
+ };
98
+
99
+ const diagnostic = (
100
+ sourceCode: string,
101
+ filePath: string,
102
+ node: AstNode,
103
+ message: string
104
+ ): WardenDiagnostic => ({
105
+ filePath,
106
+ line: offsetToLine(sourceCode, node.start),
107
+ message,
108
+ rule: RULE_NAME,
109
+ severity: 'warn',
110
+ });
111
+
112
+ /**
113
+ * Collect the literal selector strings of one trailhead definition. Returns
114
+ * `null` when any selector is dynamic — `surface-trailhead-coherence`
115
+ * already flags those, so this rule skips the comparison instead of
116
+ * double-reporting.
117
+ */
118
+ const literalSelectors = (definition: AstNode): readonly string[] | null => {
119
+ const trailsProp = findConfigProperty(definition, 'trails');
120
+ const trailsValue = trailsProp ? propertyValue(trailsProp) : undefined;
121
+ const value = unwrapExpression(trailsValue);
122
+ if (!value) {
123
+ return null;
124
+ }
125
+ const nodes =
126
+ value.type === 'ArrayExpression'
127
+ ? getNodeElements(value).filter((element) => element !== null)
128
+ : [value];
129
+ const selectors: string[] = [];
130
+ for (const node of nodes) {
131
+ const selector = extractStringOrTemplateLiteral(node);
132
+ if (selector === null) {
133
+ return null;
134
+ }
135
+ selectors.push(selector);
136
+ }
137
+ return selectors;
138
+ };
139
+
140
+ const authoredGroupSelectors = (
141
+ bindings: SurfaceBindings
142
+ ): ReadonlyMap<string, readonly string[]> => {
143
+ const groups = new Map<string, readonly string[]>();
144
+ for (const [name, value] of Object.entries(bindings)) {
145
+ const shape = classifySurfaceBinding(value);
146
+ if (shape.kind === 'group') {
147
+ groups.set(name, shape.members);
148
+ }
149
+ }
150
+ return groups;
151
+ };
152
+
153
+ const sameSelectorSet = (
154
+ first: readonly string[],
155
+ second: readonly string[]
156
+ ): boolean => {
157
+ const firstSet = new Set(first);
158
+ const secondSet = new Set(second);
159
+ return (
160
+ firstSet.size === secondSet.size &&
161
+ [...firstSet].every((selector) => secondSet.has(selector))
162
+ );
163
+ };
164
+
165
+ const formatSelectors = (selectors: readonly string[]): string =>
166
+ [...new Set(selectors)]
167
+ .toSorted()
168
+ .map((selector) => `"${selector}"`)
169
+ .join(', ');
170
+
171
+ /**
172
+ * Attribute a call-site trailhead map to a binding set: the map belongs to
173
+ * an app when at least one of its literal member selectors matches a trail
174
+ * id registered in that app's topo.
175
+ */
176
+ const mapBelongsToSet = (
177
+ trailheadMap: AstNode,
178
+ set: AuthoredMcpSurfaceBindingSet
179
+ ): boolean =>
180
+ objectProperties(trailheadMap).some((property) => {
181
+ const value = unwrapExpression(propertyValue(property));
182
+ if (value === undefined || !isTrailheadDefinition(value)) {
183
+ return false;
184
+ }
185
+ const selectors = literalSelectors(value);
186
+ return (
187
+ selectors !== null &&
188
+ selectors.some((selector) =>
189
+ set.trailIds.some((trailId) => matchesTrailPattern(trailId, selector))
190
+ )
191
+ );
192
+ });
193
+
194
+ const diagnoseTrailheadMap = (
195
+ sourceCode: string,
196
+ filePath: string,
197
+ trailheadMap: AstNode,
198
+ set: AuthoredMcpSurfaceBindingSet,
199
+ groups: ReadonlyMap<string, readonly string[]>
200
+ ): readonly WardenDiagnostic[] => {
201
+ const diagnostics: WardenDiagnostic[] = [];
202
+ const groupNames = [...groups.keys()].toSorted();
203
+
204
+ for (const property of objectProperties(trailheadMap)) {
205
+ const trailheadId = getPropertyName(getNodeKey(property));
206
+ const value = unwrapExpression(propertyValue(property));
207
+ if (!trailheadId || value === undefined || !isTrailheadDefinition(value)) {
208
+ continue;
209
+ }
210
+
211
+ const authored = groups.get(trailheadId);
212
+ if (authored === undefined) {
213
+ diagnostics.push(
214
+ diagnostic(
215
+ sourceCode,
216
+ filePath,
217
+ property,
218
+ `Call-site MCP trailhead "${trailheadId}" has no matching mcp list binding in app "${set.appName}"'s surfaceOverlay (authored groups: ${groupNames.length > 0 ? groupNames.map((name) => `"${name}"`).join(', ') : 'none'}). The call-site map overrides the lockable default at runtime — author the same binding in surfaceOverlay({ mcp }) or rename one side.`
219
+ )
220
+ );
221
+ continue;
222
+ }
223
+
224
+ const selectors = literalSelectors(value);
225
+ if (selectors === null) {
226
+ continue;
227
+ }
228
+ if (!sameSelectorSet(selectors, authored)) {
229
+ diagnostics.push(
230
+ diagnostic(
231
+ sourceCode,
232
+ filePath,
233
+ property,
234
+ `Call-site MCP trailhead "${trailheadId}" selects [${formatSelectors(selectors)}] but app "${set.appName}"'s surfaceOverlay mcp binding "${trailheadId}" authors [${formatSelectors(authored)}]. The call-site map overrides the lockable default at runtime — align the member selectors or make the intentional divergence explicit by renaming one side.`
235
+ )
236
+ );
237
+ }
238
+ }
239
+
240
+ // The override replaces the authored default whole-map at runtime, so an
241
+ // authored group missing from the call-site map is silently dropped while
242
+ // the committed lock still advertises it.
243
+ const callSiteNames = new Set(
244
+ objectProperties(trailheadMap).flatMap((property) => {
245
+ const name = getPropertyName(getNodeKey(property));
246
+ const value = unwrapExpression(propertyValue(property));
247
+ return name && value !== undefined && isTrailheadDefinition(value)
248
+ ? [name]
249
+ : [];
250
+ })
251
+ );
252
+ for (const name of groupNames) {
253
+ if (!callSiteNames.has(name)) {
254
+ diagnostics.push(
255
+ diagnostic(
256
+ sourceCode,
257
+ filePath,
258
+ trailheadMap,
259
+ `App "${set.appName}"'s surfaceOverlay mcp binding "${name}" is not carried by this call-site trailhead map, so it will not be projected at runtime while the committed lock still advertises it. Add "${name}" to the call-site map or remove it from the authored overlay.`
260
+ )
261
+ );
262
+ }
263
+ }
264
+
265
+ return diagnostics;
266
+ };
267
+
268
+ /**
269
+ * Warn when a source file's call-site MCP trailhead map diverges from the
270
+ * app-authored `surfaces` overlay's `mcp` list bindings.
271
+ */
272
+ export const trailheadOverrideDivergence: ProjectAwareWardenRule = {
273
+ check(): readonly WardenDiagnostic[] {
274
+ // Without project context there are no authored bindings to compare
275
+ // against; stay silent instead of guessing.
276
+ return [];
277
+ },
278
+ checkWithContext(
279
+ sourceCode: string,
280
+ filePath: string,
281
+ context: ProjectContext
282
+ ): readonly WardenDiagnostic[] {
283
+ const sets = (context.authoredMcpSurfaceBindingSets ?? [])
284
+ .map((set) => ({
285
+ groups: authoredGroupSelectors(set.bindings),
286
+ set,
287
+ }))
288
+ .filter(({ groups }) => groups.size > 0);
289
+ if (sets.length === 0) {
290
+ return [];
291
+ }
292
+
293
+ const ast = parse(filePath, sourceCode);
294
+ if (!ast) {
295
+ return [];
296
+ }
297
+
298
+ const seen = new Set<number>();
299
+ const diagnostics: WardenDiagnostic[] = [];
300
+ const diagnoseCandidate = (node: AstNode | undefined): void => {
301
+ const unwrapped = unwrapExpression(node);
302
+ if (
303
+ unwrapped === undefined ||
304
+ unwrapped.type !== 'ObjectExpression' ||
305
+ seen.has(unwrapped.start) ||
306
+ !isTrailheadMapCandidate(unwrapped)
307
+ ) {
308
+ return;
309
+ }
310
+ seen.add(unwrapped.start);
311
+ for (const { set, groups } of sets) {
312
+ if (!mapBelongsToSet(unwrapped, set)) {
313
+ continue;
314
+ }
315
+ diagnostics.push(
316
+ ...diagnoseTrailheadMap(sourceCode, filePath, unwrapped, set, groups)
317
+ );
318
+ }
319
+ };
320
+
321
+ walk(ast, (node) => {
322
+ if (node.type === 'Property') {
323
+ const propertyName = getPropertyName(getNodeKey(node));
324
+ if (propertyName === 'trailheads') {
325
+ diagnoseCandidate(propertyValue(node));
326
+ }
327
+ return;
328
+ }
329
+
330
+ if (node.type === 'VariableDeclarator') {
331
+ const bindingName = getNodeName(getNodeId(node));
332
+ if (
333
+ typeof bindingName === 'string' &&
334
+ isTrailheadMapBindingName(bindingName)
335
+ ) {
336
+ diagnoseCandidate(getNodeInit(node) ?? undefined);
337
+ }
338
+ return;
339
+ }
340
+
341
+ if (
342
+ (node.type === 'TSAsExpression' ||
343
+ node.type === 'TSSatisfiesExpression') &&
344
+ hasTrailheadMapTypeAnnotation(sourceCode, node)
345
+ ) {
346
+ diagnoseCandidate(node);
347
+ }
348
+ });
349
+
350
+ return diagnostics;
351
+ },
352
+ description:
353
+ 'Call-site MCP trailhead maps stay aligned with the app-authored surfaces overlay mcp bindings they override.',
354
+ name: RULE_NAME,
355
+ severity: 'warn',
356
+ };