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

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