@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.21

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 (249) hide show
  1. package/CHANGELOG.md +497 -6
  2. package/README.md +77 -26
  3. package/bin/warden.ts +50 -0
  4. package/package.json +27 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +28 -0
  7. package/src/cli.ts +1374 -103
  8. package/src/command.ts +953 -0
  9. package/src/config.ts +184 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +106 -22
  12. package/src/fix.ts +120 -0
  13. package/src/formatters.ts +79 -9
  14. package/src/guide.ts +245 -0
  15. package/src/index.ts +206 -14
  16. package/src/project-context.ts +163 -0
  17. package/src/resolve.ts +530 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/ast.ts +3176 -85
  20. package/src/rules/circular-refs.ts +154 -0
  21. package/src/rules/composes-declarations.ts +704 -0
  22. package/src/rules/context-no-surface-types.ts +68 -8
  23. package/src/rules/contour-exists.ts +251 -0
  24. package/src/rules/contour-ids.ts +15 -0
  25. package/src/rules/dead-internal-trail.ts +154 -0
  26. package/src/rules/draft-file-marking.ts +160 -0
  27. package/src/rules/draft-visible-debt.ts +87 -0
  28. package/src/rules/error-mapping-completeness.ts +288 -0
  29. package/src/rules/example-valid.ts +401 -0
  30. package/src/rules/fires-declarations.ts +758 -0
  31. package/src/rules/implementation-returns-result.ts +1265 -95
  32. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  33. package/src/rules/incomplete-crud.ts +580 -0
  34. package/src/rules/index.ts +219 -18
  35. package/src/rules/intent-propagation.ts +127 -0
  36. package/src/rules/layer-field-name-drift.ts +96 -0
  37. package/src/rules/metadata.ts +654 -0
  38. package/src/rules/missing-reconcile.ts +98 -0
  39. package/src/rules/missing-visibility.ts +110 -0
  40. package/src/rules/no-destructured-compose.ts +192 -0
  41. package/src/rules/no-dev-permit-in-source.ts +99 -0
  42. package/src/rules/no-direct-implementation-call.ts +7 -7
  43. package/src/rules/no-legacy-layer-imports.ts +211 -0
  44. package/src/rules/no-native-error-result.ts +111 -0
  45. package/src/rules/no-redundant-result-error-wrap.ts +331 -0
  46. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  47. package/src/rules/no-sync-result-assumption.ts +1134 -99
  48. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  49. package/src/rules/no-throw-in-implementation.ts +10 -9
  50. package/src/rules/no-top-level-surface.ts +389 -0
  51. package/src/rules/on-references-exist.ts +194 -0
  52. package/src/rules/orphaned-signal.ts +150 -0
  53. package/src/rules/owner-projection-parity.ts +146 -0
  54. package/src/rules/permit-governance.ts +25 -0
  55. package/src/rules/public-export-example-coverage.ts +553 -0
  56. package/src/rules/public-internal-deep-imports.ts +517 -0
  57. package/src/rules/public-output-schema.ts +29 -0
  58. package/src/rules/public-union-output-discriminants.ts +150 -0
  59. package/src/rules/read-intent-fires.ts +187 -0
  60. package/src/rules/reference-exists.ts +98 -0
  61. package/src/rules/registry-names.ts +145 -0
  62. package/src/rules/resolved-import-boundary.ts +146 -0
  63. package/src/rules/resource-declarations.ts +704 -0
  64. package/src/rules/resource-exists.ts +179 -0
  65. package/src/rules/resource-id-grammar.ts +65 -0
  66. package/src/rules/resource-mock-coverage.ts +115 -0
  67. package/src/rules/scan.ts +38 -25
  68. package/src/rules/scheduled-destroy-intent.ts +44 -0
  69. package/src/rules/signal-graph-coaching.ts +191 -0
  70. package/src/rules/specs.ts +9 -5
  71. package/src/rules/static-resource-accessor-preference.ts +657 -0
  72. package/src/rules/surface-facet-coherence.ts +370 -0
  73. package/src/rules/trail-versioning-source.ts +1094 -0
  74. package/src/rules/trail-versioning-topo.ts +172 -0
  75. package/src/rules/types.ts +270 -6
  76. package/src/rules/unmaterialized-activation-source.ts +84 -0
  77. package/src/rules/unreachable-detour-shadowing.ts +344 -0
  78. package/src/rules/valid-describe-refs.ts +160 -32
  79. package/src/rules/valid-detour-contract.ts +78 -0
  80. package/src/rules/warden-export-symmetry.ts +533 -0
  81. package/src/rules/warden-rules-use-ast.ts +996 -0
  82. package/src/rules/webhook-route-collision.ts +243 -0
  83. package/src/trails/activation-orphan.trail.ts +84 -0
  84. package/src/trails/circular-refs.trail.ts +29 -0
  85. package/src/trails/composes-declarations.trail.ts +22 -0
  86. package/src/trails/context-no-surface-types.trail.ts +21 -0
  87. package/src/trails/contour-exists.trail.ts +21 -0
  88. package/src/trails/dead-internal-trail.trail.ts +26 -0
  89. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  90. package/src/trails/draft-file-marking.trail.ts +16 -0
  91. package/src/trails/draft-visible-debt.trail.ts +16 -0
  92. package/src/trails/error-mapping-completeness.trail.ts +29 -0
  93. package/src/trails/example-valid.trail.ts +25 -0
  94. package/src/trails/fires-declarations.trail.ts +23 -0
  95. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  96. package/src/trails/implementation-returns-result.trail.ts +20 -0
  97. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  98. package/src/trails/incomplete-crud.trail.ts +39 -0
  99. package/src/trails/index.ts +78 -0
  100. package/src/trails/intent-propagation.trail.ts +30 -0
  101. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  102. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  103. package/src/trails/missing-reconcile.trail.ts +33 -0
  104. package/src/trails/missing-visibility.trail.ts +22 -0
  105. package/src/trails/no-destructured-compose.trail.ts +44 -0
  106. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  107. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  108. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  109. package/src/trails/no-native-error-result.trail.ts +18 -0
  110. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  111. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  112. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  113. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  114. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  115. package/src/trails/no-top-level-surface.trail.ts +43 -0
  116. package/src/trails/on-references-exist.trail.ts +21 -0
  117. package/src/trails/orphaned-signal.trail.ts +36 -0
  118. package/src/trails/owner-projection-parity.trail.ts +26 -0
  119. package/src/trails/pending-force.trail.ts +21 -0
  120. package/src/trails/permit-governance.trail.ts +51 -0
  121. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  122. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  123. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  124. package/src/trails/public-output-schema.trail.ts +55 -0
  125. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  126. package/src/trails/read-intent-fires.trail.ts +20 -0
  127. package/src/trails/reference-exists.trail.ts +25 -0
  128. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  129. package/src/trails/resource-declarations.trail.ts +25 -0
  130. package/src/trails/resource-exists.trail.ts +27 -0
  131. package/src/trails/resource-id-grammar.trail.ts +39 -0
  132. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  133. package/src/trails/run.ts +162 -0
  134. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  135. package/src/trails/schema.ts +194 -0
  136. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  137. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  138. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  139. package/src/trails/topo.ts +6 -0
  140. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  141. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  142. package/src/trails/valid-describe-refs.trail.ts +18 -0
  143. package/src/trails/valid-detour-contract.trail.ts +71 -0
  144. package/src/trails/version-gap.trail.ts +35 -0
  145. package/src/trails/version-pinned-compose.trail.ts +23 -0
  146. package/src/trails/version-without-examples.trail.ts +38 -0
  147. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  148. package/src/trails/warden-rules-use-ast.trail.ts +45 -0
  149. package/src/trails/webhook-route-collision.trail.ts +50 -0
  150. package/src/trails/wrap-rule.ts +213 -0
  151. package/src/workspaces.ts +238 -0
  152. package/.turbo/turbo-build.log +0 -1
  153. package/.turbo/turbo-lint.log +0 -3
  154. package/.turbo/turbo-typecheck.log +0 -1
  155. package/dist/cli.d.ts +0 -46
  156. package/dist/cli.d.ts.map +0 -1
  157. package/dist/cli.js +0 -221
  158. package/dist/cli.js.map +0 -1
  159. package/dist/drift.d.ts +0 -26
  160. package/dist/drift.d.ts.map +0 -1
  161. package/dist/drift.js +0 -27
  162. package/dist/drift.js.map +0 -1
  163. package/dist/formatters.d.ts +0 -29
  164. package/dist/formatters.d.ts.map +0 -1
  165. package/dist/formatters.js +0 -87
  166. package/dist/formatters.js.map +0 -1
  167. package/dist/index.d.ts +0 -26
  168. package/dist/index.d.ts.map +0 -1
  169. package/dist/index.js +0 -26
  170. package/dist/index.js.map +0 -1
  171. package/dist/rules/ast.d.ts +0 -41
  172. package/dist/rules/ast.d.ts.map +0 -1
  173. package/dist/rules/ast.js +0 -163
  174. package/dist/rules/ast.js.map +0 -1
  175. package/dist/rules/context-no-surface-types.d.ts +0 -12
  176. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  177. package/dist/rules/context-no-surface-types.js +0 -96
  178. package/dist/rules/context-no-surface-types.js.map +0 -1
  179. package/dist/rules/implementation-returns-result.d.ts +0 -13
  180. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  181. package/dist/rules/implementation-returns-result.js +0 -231
  182. package/dist/rules/implementation-returns-result.js.map +0 -1
  183. package/dist/rules/index.d.ts +0 -22
  184. package/dist/rules/index.d.ts.map +0 -1
  185. package/dist/rules/index.js +0 -41
  186. package/dist/rules/index.js.map +0 -1
  187. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  188. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  189. package/dist/rules/no-direct-impl-in-route.js +0 -46
  190. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  191. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  192. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  193. package/dist/rules/no-direct-implementation-call.js +0 -39
  194. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  195. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  196. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  197. package/dist/rules/no-sync-result-assumption.js +0 -98
  198. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  199. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  200. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  201. package/dist/rules/no-throw-in-detour-target.js +0 -87
  202. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  203. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  204. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  205. package/dist/rules/no-throw-in-implementation.js +0 -34
  206. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  207. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  208. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  209. package/dist/rules/prefer-schema-inference.js +0 -86
  210. package/dist/rules/prefer-schema-inference.js.map +0 -1
  211. package/dist/rules/scan.d.ts +0 -8
  212. package/dist/rules/scan.d.ts.map +0 -1
  213. package/dist/rules/scan.js +0 -32
  214. package/dist/rules/scan.js.map +0 -1
  215. package/dist/rules/specs.d.ts +0 -29
  216. package/dist/rules/specs.d.ts.map +0 -1
  217. package/dist/rules/specs.js +0 -192
  218. package/dist/rules/specs.js.map +0 -1
  219. package/dist/rules/structure.d.ts +0 -13
  220. package/dist/rules/structure.d.ts.map +0 -1
  221. package/dist/rules/structure.js +0 -142
  222. package/dist/rules/structure.js.map +0 -1
  223. package/dist/rules/types.d.ts +0 -52
  224. package/dist/rules/types.d.ts.map +0 -1
  225. package/dist/rules/types.js +0 -2
  226. package/dist/rules/types.js.map +0 -1
  227. package/dist/rules/valid-describe-refs.d.ts +0 -7
  228. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  229. package/dist/rules/valid-describe-refs.js +0 -51
  230. package/dist/rules/valid-describe-refs.js.map +0 -1
  231. package/dist/rules/valid-detour-refs.d.ts +0 -6
  232. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  233. package/dist/rules/valid-detour-refs.js +0 -116
  234. package/dist/rules/valid-detour-refs.js.map +0 -1
  235. package/src/__tests__/cli.test.ts +0 -198
  236. package/src/__tests__/drift.test.ts +0 -74
  237. package/src/__tests__/formatters.test.ts +0 -157
  238. package/src/__tests__/implementation-returns-result.test.ts +0 -75
  239. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  240. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  241. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  242. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  243. package/src/__tests__/rules.test.ts +0 -188
  244. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  245. package/src/rules/no-direct-impl-in-route.ts +0 -77
  246. package/src/rules/no-throw-in-detour-target.ts +0 -150
  247. package/src/rules/valid-detour-refs.ts +0 -187
  248. package/tsconfig.json +0 -9
  249. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Enforces ADR-0036: `@ontrails/warden` exposes only a trail-wrapper + registry
3
+ * surface. Raw rule objects stay internal to `./rules/`. The public barrel
4
+ * (`packages/warden/src/index.ts`) must:
5
+ *
6
+ * 1. Export a matching `*Trail` identifier for every entry in
7
+ * `wardenRules` / `wardenTopoRules`.
8
+ * 2. Not expose a `*Trail` identifier with no matching registry entry.
9
+ * 3. Not re-export a raw rule object by its camelCased name.
10
+ *
11
+ * Properties 1 and 2 cannot be fully derived today because the registry holds
12
+ * raw `WardenRule` objects whose `.check()` methods are called by the trail
13
+ * wrappers; flipping the dependency (registry ← trails) would require unwrapping
14
+ * trails at dispatch time and is out of scope for TRL-341. Enforcement therefore
15
+ * lives as a lint rule keyed on the warden barrel file path.
16
+ */
17
+ import { resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { walk, offsetToLine, parse } from './ast.js';
20
+ import type { AstNode } from './ast.js';
21
+ import { registeredRuleNames } from './registry-names.js';
22
+ import type { WardenDiagnostic, WardenRule } from './types.js';
23
+
24
+ const SELF_RULE_NAME = 'warden-export-symmetry';
25
+
26
+ /**
27
+ * Absolute path to this package's own `src/index.ts`, resolved from the rule's
28
+ * own module URL. Anchoring to the real on-disk location prevents the rule
29
+ * from firing against a foreign `packages/warden/src/index.ts` in a consumer
30
+ * repository with the same folder structure — the rule would otherwise compare
31
+ * that unrelated barrel against `@ontrails/warden`'s internal registry and
32
+ * emit bogus missing/orphan diagnostics that break consumer CI.
33
+ */
34
+ const SELF_BARREL_PATH = resolve(
35
+ fileURLToPath(new URL('../index.ts', import.meta.url))
36
+ );
37
+
38
+ const isTargetFile = (filePath: string): boolean =>
39
+ resolve(filePath) === SELF_BARREL_PATH;
40
+
41
+ const kebabToCamel = (value: string): string =>
42
+ value.replaceAll(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase());
43
+
44
+ interface ExportSite {
45
+ /** Public export name — what consumers see on the barrel. */
46
+ readonly name: string;
47
+ /**
48
+ * Local source binding name. For alias re-exports
49
+ * (`export { foo as bar }`) this is `foo`. Equals `name` for non-aliased
50
+ * exports and for declaration-form exports (`export const foo = ...`).
51
+ * Used by `rawRuleLeakDiagnostics` so aliasing a raw rule does not sanitize it.
52
+ */
53
+ readonly localName: string;
54
+ readonly start: number;
55
+ }
56
+
57
+ const readIdentifierOrStringName = (
58
+ node: AstNode | undefined
59
+ ): string | null => {
60
+ if (!node) {
61
+ return null;
62
+ }
63
+ if (node.type === 'Identifier') {
64
+ return (node as unknown as { name?: string }).name ?? null;
65
+ }
66
+ if (node.type === 'Literal' || node.type === 'StringLiteral') {
67
+ const { value } = node as unknown as { value?: unknown };
68
+ return typeof value === 'string' ? value : null;
69
+ }
70
+ return null;
71
+ };
72
+
73
+ const extractSpecifierNames = (
74
+ specifier: AstNode
75
+ ): { readonly name: string; readonly localName: string } | null => {
76
+ const { exported, local } = specifier as unknown as {
77
+ exported?: AstNode;
78
+ local?: AstNode;
79
+ };
80
+ const name = readIdentifierOrStringName(exported);
81
+ if (!name) {
82
+ return null;
83
+ }
84
+ const localName = readIdentifierOrStringName(local) ?? name;
85
+ return { localName, name };
86
+ };
87
+
88
+ const isTypeExportSpecifier = (specifier: AstNode): boolean =>
89
+ (specifier as unknown as { exportKind?: string }).exportKind === 'type';
90
+
91
+ const specifierSite = (specifier: AstNode): ExportSite | null => {
92
+ if (
93
+ specifier.type !== 'ExportSpecifier' ||
94
+ isTypeExportSpecifier(specifier)
95
+ ) {
96
+ return null;
97
+ }
98
+ const names = extractSpecifierNames(specifier);
99
+ return names ? { ...names, start: specifier.start } : null;
100
+ };
101
+
102
+ const TYPE_ONLY_DECL_TYPES = new Set([
103
+ 'TSTypeAliasDeclaration',
104
+ 'TSInterfaceDeclaration',
105
+ ]);
106
+
107
+ const namedSiteFromDeclId = (
108
+ declId: AstNode | undefined,
109
+ start: number
110
+ ): ExportSite | null => {
111
+ const name = readIdentifierOrStringName(declId);
112
+ return name ? { localName: name, name, start } : null;
113
+ };
114
+
115
+ /**
116
+ * Extract an identifier or `AssignmentPattern`'s left-hand identifier as a
117
+ * single export site. Returns null for anything else (nested patterns should
118
+ * be handled through `sitesFromPattern`).
119
+ */
120
+ const siteFromSimpleBinding = (
121
+ node: AstNode | undefined,
122
+ start: number
123
+ ): ExportSite | null => {
124
+ if (!node) {
125
+ return null;
126
+ }
127
+ if (node.type === 'Identifier') {
128
+ const name = readIdentifierOrStringName(node);
129
+ return name ? { localName: name, name, start } : null;
130
+ }
131
+ if (node.type === 'AssignmentPattern') {
132
+ const { left } = node as unknown as { left?: AstNode };
133
+ return left ? siteFromSimpleBinding(left, start) : null;
134
+ }
135
+ return null;
136
+ };
137
+
138
+ /** Callback type to break the recursion cycle without use-before-define. */
139
+ type PatternSitesFn = (
140
+ pattern: AstNode | undefined,
141
+ start: number
142
+ ) => readonly ExportSite[];
143
+
144
+ /**
145
+ * Compose a rename-pair site from an `ObjectPattern` property's `key` and a
146
+ * resolved value site. Rename pairs (`{ foo: bar }`) emit one site whose
147
+ * `localName` is the source binding `foo` and whose public `name` is the
148
+ * target `bar`, mirroring `extractSpecifierNames` for `export { foo as bar }`.
149
+ */
150
+ const renamePairSite = (
151
+ key: AstNode | undefined,
152
+ valueSite: ExportSite,
153
+ start: number
154
+ ): ExportSite => {
155
+ const keyName = readIdentifierOrStringName(key);
156
+ return {
157
+ localName: keyName ?? valueSite.localName,
158
+ name: valueSite.name,
159
+ start,
160
+ };
161
+ };
162
+
163
+ const isNestedPatternValue = (value: AstNode | undefined): boolean =>
164
+ !!value && value.type !== 'Identifier' && value.type !== 'AssignmentPattern';
165
+
166
+ /**
167
+ * Extract sites from a single `ObjectPattern` property.
168
+ */
169
+ const sitesFromObjectProperty = (
170
+ prop: AstNode,
171
+ start: number,
172
+ recurse: PatternSitesFn
173
+ ): readonly ExportSite[] => {
174
+ if (prop.type === 'RestElement') {
175
+ const { argument } = prop as unknown as { argument?: AstNode };
176
+ return recurse(argument, start);
177
+ }
178
+ if (prop.type !== 'Property') {
179
+ return [];
180
+ }
181
+ const { key, value } = prop as unknown as {
182
+ key?: AstNode;
183
+ value?: AstNode;
184
+ };
185
+ if (isNestedPatternValue(value)) {
186
+ return recurse(value, start);
187
+ }
188
+ const valueSite = siteFromSimpleBinding(value, start);
189
+ return valueSite ? [renamePairSite(key, valueSite, start)] : [];
190
+ };
191
+
192
+ const sitesFromArrayElement = (
193
+ element: AstNode | null,
194
+ start: number,
195
+ recurse: PatternSitesFn
196
+ ): readonly ExportSite[] => {
197
+ if (!element) {
198
+ return [];
199
+ }
200
+ if (element.type === 'RestElement') {
201
+ const { argument } = element as unknown as { argument?: AstNode };
202
+ return recurse(argument, start);
203
+ }
204
+ return recurse(element, start);
205
+ };
206
+
207
+ const sitesFromObjectPattern = (
208
+ pattern: AstNode,
209
+ start: number,
210
+ recurse: PatternSitesFn
211
+ ): readonly ExportSite[] => {
212
+ const properties =
213
+ (pattern as unknown as { properties?: readonly AstNode[] }).properties ??
214
+ [];
215
+ return properties.flatMap((prop) =>
216
+ sitesFromObjectProperty(prop, start, recurse)
217
+ );
218
+ };
219
+
220
+ const sitesFromArrayPattern = (
221
+ pattern: AstNode,
222
+ start: number,
223
+ recurse: PatternSitesFn
224
+ ): readonly ExportSite[] => {
225
+ const elements =
226
+ (pattern as unknown as { elements?: readonly (AstNode | null)[] })
227
+ .elements ?? [];
228
+ return elements.flatMap((element) =>
229
+ sitesFromArrayElement(element, start, recurse)
230
+ );
231
+ };
232
+
233
+ /**
234
+ * Recursively extract export sites from a declarator id, supporting
235
+ * `ObjectPattern` and `ArrayPattern` destructuring. Without this, a
236
+ * destructured `export const { wardenExportSymmetry } = rulesModule` silently
237
+ * bypasses orphan-trail and raw-rule-leak checks because the id is not an
238
+ * `Identifier`.
239
+ */
240
+ const sitesFromPattern: PatternSitesFn = (pattern, start) => {
241
+ if (!pattern) {
242
+ return [];
243
+ }
244
+ const simple = siteFromSimpleBinding(pattern, start);
245
+ if (simple) {
246
+ return [simple];
247
+ }
248
+ if (pattern.type === 'ObjectPattern') {
249
+ return sitesFromObjectPattern(pattern, start, sitesFromPattern);
250
+ }
251
+ if (pattern.type === 'ArrayPattern') {
252
+ return sitesFromArrayPattern(pattern, start, sitesFromPattern);
253
+ }
254
+ return [];
255
+ };
256
+
257
+ const sitesForDeclaration = (declaration: AstNode): readonly ExportSite[] => {
258
+ if (TYPE_ONLY_DECL_TYPES.has(declaration.type)) {
259
+ return [];
260
+ }
261
+ if (
262
+ declaration.type === 'FunctionDeclaration' ||
263
+ declaration.type === 'ClassDeclaration'
264
+ ) {
265
+ const { id } = declaration as unknown as { id?: AstNode };
266
+ const site = namedSiteFromDeclId(id, declaration.start);
267
+ return site ? [site] : [];
268
+ }
269
+ if (declaration.type === 'VariableDeclaration') {
270
+ const declarations =
271
+ (declaration as unknown as { declarations?: readonly AstNode[] })
272
+ .declarations ?? [];
273
+ return declarations.flatMap((declarator) => {
274
+ const { id } = declarator as unknown as { id?: AstNode };
275
+ return sitesFromPattern(id, declarator.start);
276
+ });
277
+ }
278
+ return [];
279
+ };
280
+
281
+ const sitesForExportNode = (node: AstNode): readonly ExportSite[] => {
282
+ if (node.type !== 'ExportNamedDeclaration') {
283
+ return [];
284
+ }
285
+ if ((node as unknown as { exportKind?: string }).exportKind === 'type') {
286
+ return [];
287
+ }
288
+ const { declaration } = node as unknown as { declaration?: AstNode };
289
+ if (declaration) {
290
+ return sitesForDeclaration(declaration);
291
+ }
292
+ const specifiers =
293
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
294
+ return specifiers.flatMap((specifier) => {
295
+ const site = specifierSite(specifier);
296
+ return site ? [site] : [];
297
+ });
298
+ };
299
+
300
+ const collectNamedExports = (ast: AstNode): readonly ExportSite[] => {
301
+ const sites: ExportSite[] = [];
302
+ walk(ast, (node) => {
303
+ sites.push(...sitesForExportNode(node));
304
+ });
305
+ return sites;
306
+ };
307
+
308
+ interface NamespaceReexportSite {
309
+ /** Source module path, e.g. `'./trails/index.js'`. */
310
+ readonly target: string;
311
+ /** Alias for `export * as <alias> from '...'`, null for bare `export *`. */
312
+ readonly alias: string | null;
313
+ readonly start: number;
314
+ }
315
+
316
+ const collectNamespaceReexports = (
317
+ ast: AstNode
318
+ ): readonly NamespaceReexportSite[] => {
319
+ const sites: NamespaceReexportSite[] = [];
320
+ walk(ast, (node) => {
321
+ if (node.type !== 'ExportAllDeclaration') {
322
+ return;
323
+ }
324
+ // Mirror the `ExportNamedDeclaration` guard: `export type * from ...` and
325
+ // `export type * as ns from ...` propagate types only, never runtime
326
+ // identifiers, so they cannot leak raw rule objects and must be allowed.
327
+ if ((node as unknown as { exportKind?: string }).exportKind === 'type') {
328
+ return;
329
+ }
330
+ const { source, exported } = node as unknown as {
331
+ source?: { value?: unknown };
332
+ exported?: AstNode;
333
+ };
334
+ const target =
335
+ typeof source?.value === 'string' ? source.value : '<unknown>';
336
+ // `export * as <alias> from '...'` exposes the alias as an
337
+ // `IdentifierName` / string-literal node on `exported`. Bare `export *`
338
+ // has `exported === null`.
339
+ const alias = readIdentifierOrStringName(exported);
340
+ sites.push({ alias, start: node.start, target });
341
+ });
342
+ return sites;
343
+ };
344
+
345
+ const formatNamespaceReexport = (site: NamespaceReexportSite): string =>
346
+ site.alias
347
+ ? `* as ${site.alias} from '${site.target}'`
348
+ : `* from '${site.target}'`;
349
+
350
+ const namespaceReexportDiagnostics = (
351
+ sourceCode: string,
352
+ filePath: string,
353
+ sites: readonly NamespaceReexportSite[]
354
+ ): readonly WardenDiagnostic[] =>
355
+ sites.map((site) => ({
356
+ filePath,
357
+ line: offsetToLine(sourceCode, site.start),
358
+ message:
359
+ `warden-export-symmetry: namespace re-export "export ${formatNamespaceReexport(site)}" is not permitted on the warden public barrel. ` +
360
+ 'The rule cannot verify registry ↔ trail symmetry through a star export — list each *Trail by name instead (ADR-0036).',
361
+ rule: 'warden-export-symmetry',
362
+ severity: 'error' as const,
363
+ }));
364
+
365
+ const buildRegistryNameSets = (): {
366
+ readonly ruleNames: readonly string[];
367
+ readonly expectedTrailExports: ReadonlySet<string>;
368
+ readonly rawRuleCamelNames: ReadonlySet<string>;
369
+ } => {
370
+ const ruleNames = [...registeredRuleNames, SELF_RULE_NAME];
371
+ const camelNames = ruleNames.map(kebabToCamel);
372
+ return {
373
+ expectedTrailExports: new Set(camelNames.map((name) => `${name}Trail`)),
374
+ rawRuleCamelNames: new Set(camelNames),
375
+ ruleNames,
376
+ };
377
+ };
378
+
379
+ const missingTrailDiagnostics = (
380
+ filePath: string,
381
+ expected: ReadonlySet<string>,
382
+ present: ReadonlySet<string>
383
+ ): readonly WardenDiagnostic[] =>
384
+ [...expected]
385
+ .filter((name) => !present.has(name))
386
+ .map((name) => ({
387
+ filePath,
388
+ line: 1,
389
+ message:
390
+ `warden-export-symmetry: missing trail export "${name}" for registered warden rule. ` +
391
+ 'Every wardenRules / wardenTopoRules entry must have a matching *Trail export on the public barrel (ADR-0036).',
392
+ rule: 'warden-export-symmetry',
393
+ severity: 'error' as const,
394
+ }));
395
+
396
+ const orphanTrailDiagnostics = (
397
+ sourceCode: string,
398
+ filePath: string,
399
+ exports: readonly ExportSite[],
400
+ expected: ReadonlySet<string>
401
+ ): readonly WardenDiagnostic[] =>
402
+ exports
403
+ .filter((site) => site.name.endsWith('Trail') && !expected.has(site.name))
404
+ .map((site) => ({
405
+ filePath,
406
+ line: offsetToLine(sourceCode, site.start),
407
+ message:
408
+ `warden-export-symmetry: orphan trail export "${site.name}" has no matching wardenRules / wardenTopoRules entry. ` +
409
+ 'Remove the export or register the corresponding rule (ADR-0036).',
410
+ rule: 'warden-export-symmetry',
411
+ severity: 'error' as const,
412
+ }));
413
+
414
+ const pickRawRuleMatch = (
415
+ site: ExportSite,
416
+ rawNames: ReadonlySet<string>
417
+ ): string | null => {
418
+ if (rawNames.has(site.localName)) {
419
+ return site.localName;
420
+ }
421
+ if (rawNames.has(site.name)) {
422
+ return site.name;
423
+ }
424
+ return null;
425
+ };
426
+
427
+ const rawRuleLeakDiagnostics = (
428
+ sourceCode: string,
429
+ filePath: string,
430
+ exports: readonly ExportSite[],
431
+ rawNames: ReadonlySet<string>
432
+ ): readonly WardenDiagnostic[] =>
433
+ exports.flatMap((site) => {
434
+ // Check BOTH the public name and the local source binding — aliasing a
435
+ // raw rule (`export { wardenExportSymmetry as disguised }`) must not
436
+ // sanitize the leak. Prefer the raw-matching name in the diagnostic so
437
+ // the incident points at the actual rule identifier.
438
+ const matched = pickRawRuleMatch(site, rawNames);
439
+ if (!matched) {
440
+ return [];
441
+ }
442
+ const alias =
443
+ site.localName === site.name ? '' : ` (aliased as "${site.name}")`;
444
+ return [
445
+ {
446
+ filePath,
447
+ line: offsetToLine(sourceCode, site.start),
448
+ message:
449
+ `warden-export-symmetry: raw rule export "${matched}"${alias} must not appear on the public barrel. ` +
450
+ 'Raw WardenRule objects are internal; expose the matching *Trail wrapper instead (ADR-0036).',
451
+ rule: 'warden-export-symmetry',
452
+ severity: 'error' as const,
453
+ },
454
+ ];
455
+ });
456
+
457
+ const collectDefaultExports = (ast: AstNode): readonly ExportSite[] => {
458
+ const sites: ExportSite[] = [];
459
+ walk(ast, (node) => {
460
+ if (node.type !== 'ExportDefaultDeclaration') {
461
+ return;
462
+ }
463
+ sites.push({ localName: 'default', name: 'default', start: node.start });
464
+ });
465
+ return sites;
466
+ };
467
+
468
+ const defaultExportDiagnostics = (
469
+ sourceCode: string,
470
+ filePath: string,
471
+ sites: readonly ExportSite[]
472
+ ): readonly WardenDiagnostic[] =>
473
+ sites.map((site) => ({
474
+ filePath,
475
+ line: offsetToLine(sourceCode, site.start),
476
+ message:
477
+ 'warden-export-symmetry: default export is not permitted on the warden public barrel. ' +
478
+ 'Use named exports only so registry ↔ trail symmetry is discoverable (ADR-0036).',
479
+ rule: 'warden-export-symmetry',
480
+ severity: 'error' as const,
481
+ }));
482
+
483
+ const analyzeBarrel = (
484
+ sourceCode: string,
485
+ filePath: string,
486
+ ast: AstNode
487
+ ): readonly WardenDiagnostic[] => {
488
+ const exports = collectNamedExports(ast);
489
+ const presentExports = new Set(exports.map((site) => site.name));
490
+ const { expectedTrailExports, rawRuleCamelNames } = buildRegistryNameSets();
491
+
492
+ return [
493
+ ...namespaceReexportDiagnostics(
494
+ sourceCode,
495
+ filePath,
496
+ collectNamespaceReexports(ast)
497
+ ),
498
+ ...defaultExportDiagnostics(
499
+ sourceCode,
500
+ filePath,
501
+ collectDefaultExports(ast)
502
+ ),
503
+ ...missingTrailDiagnostics(filePath, expectedTrailExports, presentExports),
504
+ ...orphanTrailDiagnostics(
505
+ sourceCode,
506
+ filePath,
507
+ exports,
508
+ expectedTrailExports
509
+ ),
510
+ ...rawRuleLeakDiagnostics(sourceCode, filePath, exports, rawRuleCamelNames),
511
+ ];
512
+ };
513
+
514
+ /**
515
+ * Warden rule enforcing ADR-0036 registry ↔ trail export symmetry on the
516
+ * `@ontrails/warden` public barrel.
517
+ */
518
+ export const wardenExportSymmetry: WardenRule = {
519
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
520
+ if (!isTargetFile(filePath)) {
521
+ return [];
522
+ }
523
+ const ast = parse(filePath, sourceCode);
524
+ if (!ast) {
525
+ return [];
526
+ }
527
+ return analyzeBarrel(sourceCode, filePath, ast);
528
+ },
529
+ description:
530
+ 'Enforces ADR-0036: every wardenRules / wardenTopoRules entry has a matching *Trail export, no orphan *Trail exports, and no raw rule objects leak onto the @ontrails/warden public barrel.',
531
+ name: 'warden-export-symmetry',
532
+ severity: 'error',
533
+ };