@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
@@ -5,7 +5,16 @@
5
5
  * imports in comments or strings.
6
6
  */
7
7
 
8
- import { offsetToLine, parse, walk } from './ast.js';
8
+ import {
9
+ getNodeCallee,
10
+ getNodeComputed,
11
+ getNodeName,
12
+ getNodeProperty,
13
+ offsetToLine,
14
+ parse,
15
+ walk,
16
+ } from '@ontrails/source';
17
+ import type { AstNode } from '@ontrails/source';
9
18
  import type { WardenDiagnostic, WardenRule } from './types.js';
10
19
 
11
20
  const SURFACE_MODULES = new Set([
@@ -29,17 +38,73 @@ const SURFACE_TYPE_NAMES = new Set([
29
38
  'ServerResponse',
30
39
  ]);
31
40
 
32
- interface AstNode {
33
- readonly type: string;
34
- readonly start: number;
35
- readonly [key: string]: unknown;
36
- }
37
-
38
41
  interface ImportSpecifier {
39
42
  readonly local?: { readonly name?: string };
40
43
  readonly imported?: { readonly name?: string };
41
44
  }
42
45
 
46
+ const isBareTrailCallee = (callee: AstNode): boolean => {
47
+ if (callee.type !== 'Identifier') {
48
+ return false;
49
+ }
50
+ return getNodeName(callee) === 'trail';
51
+ };
52
+
53
+ const isNamespacedTrailCallee = (callee: AstNode): boolean => {
54
+ if (
55
+ callee.type !== 'MemberExpression' &&
56
+ callee.type !== 'StaticMemberExpression'
57
+ ) {
58
+ return false;
59
+ }
60
+ // Skip computed access like `ns[trail]()` — the bracketed expression may
61
+ // resolve to any runtime value, not the `trail` primitive, even when it
62
+ // happens to be an identifier literally named `trail`.
63
+ if (getNodeComputed(callee) === true) {
64
+ return false;
65
+ }
66
+ const prop = getNodeProperty(callee);
67
+ if (prop?.type !== 'Identifier') {
68
+ return false;
69
+ }
70
+ return getNodeName(prop) === 'trail';
71
+ };
72
+
73
+ /**
74
+ * True when `ast` contains a `trail(...)` call expression — i.e. this file
75
+ * looks like a trail definition. AST-based replacement for the legacy
76
+ * `/\btrail\s*\(/.test(sourceCode)` gate, which fired on string literals,
77
+ * comments, and docstrings.
78
+ *
79
+ * @remarks
80
+ * Both bare-identifier `trail(...)` and namespaced `ns.trail(...)` callees
81
+ * are recognized, so files using either `import { trail }` or
82
+ * `import * as ns from '@ontrails/core'` are detected as trail definitions.
83
+ *
84
+ * The inner `if (found)` guard skips further work in each callback invocation,
85
+ * but the shared `walk` helper from `@ontrails/source` exposes no abort
86
+ * mechanism, so
87
+ * the full tree is still traversed once a match is seen. Acceptable: `walk`
88
+ * is cheap and this rule only runs on files that already matched a path
89
+ * filter upstream.
90
+ */
91
+ const hasTrailCall = (ast: AstNode): boolean => {
92
+ let found = false;
93
+ walk(ast, (node) => {
94
+ if (found || node.type !== 'CallExpression') {
95
+ return;
96
+ }
97
+ const callee = getNodeCallee(node);
98
+ if (!callee) {
99
+ return;
100
+ }
101
+ if (isBareTrailCallee(callee) || isNamespacedTrailCallee(callee)) {
102
+ found = true;
103
+ }
104
+ });
105
+ return found;
106
+ };
107
+
43
108
  const makeDiag = (
44
109
  filePath: string,
45
110
  sourceCode: string,
@@ -92,7 +157,7 @@ const checkSpecifiersForSurfaceTypes = (
92
157
  filePath,
93
158
  sourceCode,
94
159
  node,
95
- `Do not import surface type "${typeName}" in trail implementation files.`
160
+ `Do not import surface type "${typeName}" in trail files.`
96
161
  );
97
162
  };
98
163
 
@@ -111,7 +176,7 @@ const classifyImport = (
111
176
  filePath,
112
177
  sourceCode,
113
178
  node,
114
- `Do not import from surface module "${moduleName}" in trail implementation files.`
179
+ `Do not import from surface module "${moduleName}" in trail files.`
115
180
  );
116
181
  }
117
182
 
@@ -119,18 +184,17 @@ const classifyImport = (
119
184
  };
120
185
 
121
186
  /**
122
- * Detects imports of surface-specific types in trail implementation files.
187
+ * Detects imports of surface-specific types in trail files.
123
188
  */
124
189
  export const contextNoSurfaceTypes: WardenRule = {
125
190
  check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
126
- if (!/\btrail\s*\(/.test(sourceCode)) {
127
- return [];
128
- }
129
-
130
191
  const ast = parse(filePath, sourceCode);
131
192
  if (!ast) {
132
193
  return [];
133
194
  }
195
+ if (!hasTrailCall(ast)) {
196
+ return [];
197
+ }
134
198
 
135
199
  const diagnostics: WardenDiagnostic[] = [];
136
200
  walk(ast, (node) => {
@@ -143,7 +207,7 @@ export const contextNoSurfaceTypes: WardenRule = {
143
207
  return diagnostics;
144
208
  },
145
209
  description:
146
- 'Disallow surface-specific type imports (Request, Response, McpSession, etc.) in trail implementation files.',
210
+ 'Disallow surface-specific type imports (Request, Response, McpSession, etc.) in trail files.',
147
211
  name: 'context-no-surface-types',
148
212
 
149
213
  severity: 'error',
@@ -0,0 +1,161 @@
1
+ import { collectComposeTargetTrailIds } from './source/composition.js';
2
+ import {
3
+ findConfigProperty,
4
+ findTrailDefinitions,
5
+ getNodeValue,
6
+ getStringValue,
7
+ isStringLiteral,
8
+ offsetToLine,
9
+ parse,
10
+ } from '@ontrails/source';
11
+ import type { AstNode } from '@ontrails/source';
12
+ import { isTestFile } from './scan.js';
13
+ import type {
14
+ ProjectAwareWardenRule,
15
+ ProjectContext,
16
+ WardenDiagnostic,
17
+ } from './types.js';
18
+
19
+ const isNonEmptyActivationValue = (onValue: AstNode): boolean => {
20
+ // Identifier reference (e.g. `on: signalsArray`) — conservatively treat as
21
+ // having activation to avoid false positives. We can't cheaply resolve what
22
+ // the identifier binds to, so assume it's a non-empty activation.
23
+ if (onValue.type === 'Identifier') {
24
+ return true;
25
+ }
26
+ if (onValue.type !== 'ArrayExpression') {
27
+ return false;
28
+ }
29
+ const elements = onValue['elements'] as readonly AstNode[] | undefined;
30
+ return (elements?.length ?? 0) > 0;
31
+ };
32
+
33
+ const hasOnActivation = (config: AstNode): boolean => {
34
+ const onProp = findConfigProperty(config, 'on');
35
+ const onValue = onProp?.value as AstNode | undefined;
36
+ return onValue ? isNonEmptyActivationValue(onValue) : false;
37
+ };
38
+
39
+ const hasExplicitInternalVisibility = (config: AstNode): boolean => {
40
+ const visibilityProp = findConfigProperty(config, 'visibility');
41
+ const visibilityValue = visibilityProp?.value as AstNode | undefined;
42
+ return (
43
+ !!visibilityValue &&
44
+ isStringLiteral(visibilityValue) &&
45
+ getStringValue(visibilityValue) === 'internal'
46
+ );
47
+ };
48
+
49
+ /** Check legacy `meta: { internal: true }` convention (mirrors runtime effectiveVisibility). */
50
+ const hasLegacyMetaInternal = (config: AstNode): boolean => {
51
+ const metaProp = findConfigProperty(config, 'meta');
52
+ const metaValue = metaProp?.value as AstNode | undefined;
53
+ if (!metaValue || metaValue.type !== 'ObjectExpression') {
54
+ return false;
55
+ }
56
+ const internalProp = findConfigProperty(metaValue, 'internal');
57
+ const internalValue = internalProp?.value as AstNode | undefined;
58
+ return (
59
+ internalValue?.type === 'BooleanLiteral' &&
60
+ getNodeValue(internalValue) === true
61
+ );
62
+ };
63
+
64
+ const isInternalTrail = (config: AstNode): boolean =>
65
+ hasExplicitInternalVisibility(config) || hasLegacyMetaInternal(config);
66
+
67
+ const buildDeadInternalTrailDiagnostic = (
68
+ trailId: string,
69
+ filePath: string,
70
+ line: number
71
+ ): WardenDiagnostic => ({
72
+ filePath,
73
+ line,
74
+ message: `Trail "${trailId}" is marked visibility: 'internal' but nothing composes it and it has no on: activation. Internal trails should stay reachable through ctx.compose() or reactive activation.`,
75
+ rule: 'dead-internal-trail',
76
+ severity: 'warn',
77
+ });
78
+
79
+ const checkDeadInternalTrails = (
80
+ ast: AstNode | null,
81
+ sourceCode: string,
82
+ filePath: string,
83
+ composedTrailIds: ReadonlySet<string>,
84
+ topoTrailIds?: ReadonlySet<string> | undefined
85
+ ): readonly WardenDiagnostic[] => {
86
+ if (isTestFile(filePath) || !ast) {
87
+ return [];
88
+ }
89
+
90
+ const diagnostics: WardenDiagnostic[] = [];
91
+
92
+ for (const def of findTrailDefinitions(ast)) {
93
+ if (def.kind !== 'trail' || !isInternalTrail(def.config)) {
94
+ continue;
95
+ }
96
+
97
+ if (
98
+ hasOnActivation(def.config) ||
99
+ composedTrailIds.has(def.id) ||
100
+ topoTrailIds?.has(def.id)
101
+ ) {
102
+ continue;
103
+ }
104
+
105
+ diagnostics.push(
106
+ buildDeadInternalTrailDiagnostic(
107
+ def.id,
108
+ filePath,
109
+ offsetToLine(sourceCode, def.start)
110
+ )
111
+ );
112
+ }
113
+
114
+ return diagnostics;
115
+ };
116
+
117
+ export const deadInternalTrail: ProjectAwareWardenRule = {
118
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
119
+ const ast = parse(filePath, sourceCode);
120
+ return checkDeadInternalTrails(
121
+ ast,
122
+ sourceCode,
123
+ filePath,
124
+ ast ? collectComposeTargetTrailIds(ast, sourceCode) : new Set<string>()
125
+ );
126
+ },
127
+ checkWithContext(
128
+ sourceCode: string,
129
+ filePath: string,
130
+ context: ProjectContext
131
+ ): readonly WardenDiagnostic[] {
132
+ const ast = parse(filePath, sourceCode);
133
+ const localComposeTargetTrailIds = ast
134
+ ? collectComposeTargetTrailIds(ast, sourceCode)
135
+ : new Set<string>();
136
+ // Union project-wide compose evidence with the file-local evidence rather
137
+ // than preferring one over the other. The project context only collects
138
+ // compose edges from registered app topos, so a trail defined in a package
139
+ // that is scanned but not part of any registered topo (e.g. an internal
140
+ // child composed in its own module) would be absent from the context set
141
+ // yet present in the local set. Preferring the context set alone produced a
142
+ // false dead-internal-trail warning for those same-file compositions.
143
+ const composeTargetTrailIds = context.composeTargetTrailIds
144
+ ? new Set<string>([
145
+ ...context.composeTargetTrailIds,
146
+ ...localComposeTargetTrailIds,
147
+ ])
148
+ : localComposeTargetTrailIds;
149
+ return checkDeadInternalTrails(
150
+ ast,
151
+ sourceCode,
152
+ filePath,
153
+ composeTargetTrailIds,
154
+ context.topoTrailIds
155
+ );
156
+ },
157
+ description:
158
+ 'Warn when an internal trail has no compositions anywhere in the project and no on: activation.',
159
+ name: 'dead-internal-trail',
160
+ severity: 'warn',
161
+ };
@@ -0,0 +1,258 @@
1
+ import { collectComposeTargetTrailIds } from './source/composition.js';
2
+ import {
3
+ findConfigProperty,
4
+ findTrailDefinitions,
5
+ getNodeDeclaration,
6
+ getNodeDeclarations,
7
+ getNodeId,
8
+ getNodeInit,
9
+ getNodeLocal,
10
+ getNodeName,
11
+ getNodeSpecifiers,
12
+ getNodeValue,
13
+ getStringValue,
14
+ isStringLiteral,
15
+ offsetToLine,
16
+ parse,
17
+ walkWithParents,
18
+ } from '@ontrails/source';
19
+ import type { AstNode, AstParentContext } from '@ontrails/source';
20
+ import { isTestFile } from './scan.js';
21
+ import type {
22
+ ProjectAwareWardenRule,
23
+ ProjectContext,
24
+ WardenDiagnostic,
25
+ } from './types.js';
26
+
27
+ const RULE_NAME = 'dead-public-trail';
28
+
29
+ const isNonEmptyActivationValue = (onValue: AstNode): boolean => {
30
+ if (onValue.type === 'Identifier') {
31
+ return true;
32
+ }
33
+ if (onValue.type !== 'ArrayExpression') {
34
+ return false;
35
+ }
36
+ const elements = onValue['elements'] as readonly AstNode[] | undefined;
37
+ return (elements?.length ?? 0) > 0;
38
+ };
39
+
40
+ const hasOnActivation = (config: AstNode): boolean => {
41
+ const onProp = findConfigProperty(config, 'on');
42
+ const onValue = onProp?.value as AstNode | undefined;
43
+ return onValue ? isNonEmptyActivationValue(onValue) : false;
44
+ };
45
+
46
+ const hasExplicitInternalVisibility = (config: AstNode): boolean => {
47
+ const visibilityProp = findConfigProperty(config, 'visibility');
48
+ const visibilityValue = visibilityProp?.value as AstNode | undefined;
49
+ return (
50
+ !!visibilityValue &&
51
+ isStringLiteral(visibilityValue) &&
52
+ getStringValue(visibilityValue) === 'internal'
53
+ );
54
+ };
55
+
56
+ const hasLegacyMetaInternal = (config: AstNode): boolean => {
57
+ const metaProp = findConfigProperty(config, 'meta');
58
+ const metaValue = metaProp?.value as AstNode | undefined;
59
+ if (!metaValue || metaValue.type !== 'ObjectExpression') {
60
+ return false;
61
+ }
62
+ const internalProp = findConfigProperty(metaValue, 'internal');
63
+ const internalValue = internalProp?.value as AstNode | undefined;
64
+ return (
65
+ internalValue?.type === 'BooleanLiteral' &&
66
+ getNodeValue(internalValue) === true
67
+ );
68
+ };
69
+
70
+ const isInternalTrail = (config: AstNode): boolean =>
71
+ hasExplicitInternalVisibility(config) || hasLegacyMetaInternal(config);
72
+
73
+ const isExportDeclaration = (node: AstNode | null | undefined): boolean =>
74
+ node?.type === 'ExportNamedDeclaration' ||
75
+ node?.type === 'ExportDefaultDeclaration';
76
+
77
+ const identifierName = (node: AstNode | undefined): string | null =>
78
+ node?.type === 'Identifier' ? (getNodeName(node) ?? null) : null;
79
+
80
+ const trailBindingStarts = (ast: AstNode): ReadonlyMap<string, number> => {
81
+ const trailStarts = new Set(
82
+ findTrailDefinitions(ast)
83
+ .filter((definition) => definition.kind === 'trail')
84
+ .map((definition) => definition.start)
85
+ );
86
+ const bindings = new Map<string, number>();
87
+
88
+ walkWithParents(ast, (node: AstNode) => {
89
+ if (node.type !== 'VariableDeclarator') {
90
+ return;
91
+ }
92
+ const id = getNodeId(node);
93
+ const init = getNodeInit(node);
94
+ const name = identifierName(id);
95
+ if (name && init && trailStarts.has(init.start)) {
96
+ bindings.set(name, init.start);
97
+ }
98
+ });
99
+
100
+ return bindings;
101
+ };
102
+
103
+ const addExportedSpecifierStarts = (
104
+ node: AstNode,
105
+ bindings: ReadonlyMap<string, number>,
106
+ exported: Set<number>
107
+ ): void => {
108
+ const specifiers = getNodeSpecifiers(node) ?? [];
109
+ for (const specifier of specifiers) {
110
+ const local = getNodeLocal(specifier);
111
+ const name = identifierName(local);
112
+ const start = name ? bindings.get(name) : undefined;
113
+ if (start !== undefined) {
114
+ exported.add(start);
115
+ }
116
+ }
117
+ };
118
+
119
+ const exportedTrailStarts = (ast: AstNode): ReadonlySet<number> => {
120
+ const exported = new Set<number>();
121
+ const bindings = trailBindingStarts(ast);
122
+
123
+ walkWithParents(ast, (node: AstNode, context: AstParentContext) => {
124
+ if (node.type !== 'CallExpression') {
125
+ return;
126
+ }
127
+
128
+ // Catch `export default trail(...)`. Exported variable declarations are
129
+ // handled by the VariableDeclaration walk below.
130
+ if (isExportDeclaration(context.parent)) {
131
+ exported.add(node.start);
132
+ }
133
+ });
134
+
135
+ walkWithParents(ast, (node: AstNode, context: AstParentContext) => {
136
+ if (node.type !== 'VariableDeclaration') {
137
+ return;
138
+ }
139
+ if (!isExportDeclaration(context.parent)) {
140
+ return;
141
+ }
142
+ const declarations = getNodeDeclarations(node) ?? [];
143
+ for (const declaration of declarations) {
144
+ const init = getNodeInit(declaration);
145
+ if (init?.type === 'CallExpression') {
146
+ exported.add(init.start);
147
+ }
148
+ }
149
+ });
150
+
151
+ walkWithParents(ast, (node: AstNode) => {
152
+ if (node.type === 'ExportNamedDeclaration') {
153
+ addExportedSpecifierStarts(node, bindings, exported);
154
+ return;
155
+ }
156
+
157
+ if (node.type !== 'ExportDefaultDeclaration') {
158
+ return;
159
+ }
160
+ const declaration = getNodeDeclaration(node);
161
+ const name = identifierName(declaration);
162
+ const start = name ? bindings.get(name) : undefined;
163
+ if (start !== undefined) {
164
+ exported.add(start);
165
+ }
166
+ });
167
+
168
+ return exported;
169
+ };
170
+
171
+ const buildDiagnostic = (
172
+ trailId: string,
173
+ filePath: string,
174
+ line: number
175
+ ): WardenDiagnostic => ({
176
+ filePath,
177
+ line,
178
+ message: `Exported public trail "${trailId}" is not registered in a configured app topo, composed by another trail, or activated by on:. Anchor the contract in a topo, compose it, mark it internal, or remove the public export.`,
179
+ rule: RULE_NAME,
180
+ severity: 'warn',
181
+ });
182
+
183
+ const unionComposeTargetIds = (
184
+ ast: AstNode | null,
185
+ sourceCode: string,
186
+ context: ProjectContext
187
+ ): ReadonlySet<string> => {
188
+ const local = ast
189
+ ? collectComposeTargetTrailIds(ast, sourceCode)
190
+ : new Set<string>();
191
+ return context.composeTargetTrailIds
192
+ ? new Set([...context.composeTargetTrailIds, ...local])
193
+ : local;
194
+ };
195
+
196
+ const checkDeadPublicTrails = (
197
+ ast: AstNode | null,
198
+ sourceCode: string,
199
+ filePath: string,
200
+ context: ProjectContext
201
+ ): readonly WardenDiagnostic[] => {
202
+ if (isTestFile(filePath) || !ast || !context.topoTrailIds) {
203
+ return [];
204
+ }
205
+
206
+ const exportedStarts = exportedTrailStarts(ast);
207
+ const composeTargetTrailIds = unionComposeTargetIds(ast, sourceCode, context);
208
+ const diagnostics: WardenDiagnostic[] = [];
209
+
210
+ for (const def of findTrailDefinitions(ast)) {
211
+ if (
212
+ def.kind !== 'trail' ||
213
+ isInternalTrail(def.config) ||
214
+ !exportedStarts.has(def.start)
215
+ ) {
216
+ continue;
217
+ }
218
+
219
+ if (
220
+ hasOnActivation(def.config) ||
221
+ composeTargetTrailIds.has(def.id) ||
222
+ context.topoTrailIds.has(def.id)
223
+ ) {
224
+ continue;
225
+ }
226
+
227
+ diagnostics.push(
228
+ buildDiagnostic(def.id, filePath, offsetToLine(sourceCode, def.start))
229
+ );
230
+ }
231
+
232
+ return diagnostics;
233
+ };
234
+
235
+ export const deadPublicTrail: ProjectAwareWardenRule = {
236
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
237
+ const ast = parse(filePath, sourceCode);
238
+ return checkDeadPublicTrails(ast, sourceCode, filePath, {
239
+ knownTrailIds: new Set<string>(),
240
+ });
241
+ },
242
+ checkWithContext(
243
+ sourceCode: string,
244
+ filePath: string,
245
+ context: ProjectContext
246
+ ): readonly WardenDiagnostic[] {
247
+ return checkDeadPublicTrails(
248
+ parse(filePath, sourceCode),
249
+ sourceCode,
250
+ filePath,
251
+ context
252
+ );
253
+ },
254
+ description:
255
+ 'Warn when an exported public trail is neither registered in configured app topos nor reachable through composition or activation.',
256
+ name: RULE_NAME,
257
+ severity: 'warn',
258
+ };