@ontrails/warden 1.0.0-beta.3 → 1.0.0-beta.32

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 (260) hide show
  1. package/CHANGELOG.md +674 -9
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +28 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +137 -0
  7. package/src/cli.ts +1451 -104
  8. package/src/command.ts +966 -0
  9. package/src/config.ts +193 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +122 -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 +217 -14
  16. package/src/project-context.ts +192 -0
  17. package/src/project-rules.ts +290 -0
  18. package/src/resolve.ts +531 -0
  19. package/src/rules/activation-orphan.ts +97 -0
  20. package/src/rules/ast.ts +4008 -86
  21. package/src/rules/circular-refs.ts +154 -0
  22. package/src/rules/cli-command-route-coherence.ts +177 -0
  23. package/src/rules/composes-declarations.ts +837 -0
  24. package/src/rules/context-no-surface-types.ts +78 -15
  25. package/src/rules/contour-exists.ts +251 -0
  26. package/src/rules/contour-ids.ts +15 -0
  27. package/src/rules/dead-internal-trail.ts +161 -0
  28. package/src/rules/dead-public-trail.ts +258 -0
  29. package/src/rules/draft-file-marking.ts +160 -0
  30. package/src/rules/draft-visible-debt.ts +87 -0
  31. package/src/rules/duplicate-public-contract.ts +91 -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 +735 -0
  35. package/src/rules/implementation-returns-result.ts +1409 -166
  36. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  37. package/src/rules/incomplete-crud.ts +581 -0
  38. package/src/rules/index.ts +235 -18
  39. package/src/rules/intent-propagation.ts +127 -0
  40. package/src/rules/layer-field-name-drift.ts +102 -0
  41. package/src/rules/library-projection-coherence.ts +100 -0
  42. package/src/rules/metadata.ts +755 -0
  43. package/src/rules/missing-reconcile.ts +98 -0
  44. package/src/rules/missing-visibility.ts +110 -0
  45. package/src/rules/no-destructured-compose.ts +194 -0
  46. package/src/rules/no-dev-permit-in-source.ts +99 -0
  47. package/src/rules/no-direct-implementation-call.ts +7 -7
  48. package/src/rules/no-legacy-layer-imports.ts +211 -0
  49. package/src/rules/no-native-error-result.ts +118 -0
  50. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  51. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  52. package/src/rules/no-sync-result-assumption.ts +1135 -98
  53. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  54. package/src/rules/no-throw-in-implementation.ts +10 -9
  55. package/src/rules/no-top-level-surface.ts +371 -0
  56. package/src/rules/on-references-exist.ts +194 -0
  57. package/src/rules/orphaned-signal.ts +150 -0
  58. package/src/rules/owner-projection-parity.ts +143 -0
  59. package/src/rules/permit-governance.ts +25 -0
  60. package/src/rules/public-export-example-coverage.ts +562 -0
  61. package/src/rules/public-internal-deep-imports.ts +457 -0
  62. package/src/rules/public-output-schema.ts +29 -0
  63. package/src/rules/public-union-output-discriminants.ts +150 -0
  64. package/src/rules/read-intent-fires.ts +187 -0
  65. package/src/rules/reference-exists.ts +98 -0
  66. package/src/rules/registry-names.ts +155 -0
  67. package/src/rules/resolved-import-boundary.ts +146 -0
  68. package/src/rules/resource-declarations.ts +693 -0
  69. package/src/rules/resource-exists.ts +179 -0
  70. package/src/rules/resource-id-grammar.ts +65 -0
  71. package/src/rules/resource-mock-coverage.ts +115 -0
  72. package/src/rules/scan.ts +38 -25
  73. package/src/rules/scheduled-destroy-intent.ts +44 -0
  74. package/src/rules/signal-graph-coaching.ts +191 -0
  75. package/src/rules/specs.ts +9 -5
  76. package/src/rules/static-resource-accessor-preference.ts +649 -0
  77. package/src/rules/surface-facet-coherence.ts +362 -0
  78. package/src/rules/trail-fork-coaching.ts +616 -0
  79. package/src/rules/trail-versioning-source.ts +1072 -0
  80. package/src/rules/trail-versioning-topo.ts +172 -0
  81. package/src/rules/types.ts +297 -8
  82. package/src/rules/unmaterialized-activation-source.ts +84 -0
  83. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  84. package/src/rules/valid-describe-refs.ts +162 -32
  85. package/src/rules/valid-detour-contract.ts +78 -0
  86. package/src/rules/warden-export-symmetry.ts +540 -0
  87. package/src/rules/warden-rules-use-ast.ts +1114 -0
  88. package/src/rules/webhook-route-collision.ts +243 -0
  89. package/src/trails/activation-orphan.trail.ts +84 -0
  90. package/src/trails/circular-refs.trail.ts +29 -0
  91. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  92. package/src/trails/composes-declarations.trail.ts +22 -0
  93. package/src/trails/context-no-surface-types.trail.ts +21 -0
  94. package/src/trails/contour-exists.trail.ts +21 -0
  95. package/src/trails/dead-internal-trail.trail.ts +26 -0
  96. package/src/trails/dead-public-trail.trail.ts +31 -0
  97. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  98. package/src/trails/draft-file-marking.trail.ts +16 -0
  99. package/src/trails/draft-visible-debt.trail.ts +16 -0
  100. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  101. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  102. package/src/trails/example-valid.trail.ts +25 -0
  103. package/src/trails/fires-declarations.trail.ts +23 -0
  104. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  105. package/src/trails/implementation-returns-result.trail.ts +20 -0
  106. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  107. package/src/trails/incomplete-crud.trail.ts +39 -0
  108. package/src/trails/index.ts +83 -0
  109. package/src/trails/intent-propagation.trail.ts +30 -0
  110. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  111. package/src/trails/library-projection-coherence.trail.ts +43 -0
  112. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  113. package/src/trails/missing-reconcile.trail.ts +33 -0
  114. package/src/trails/missing-visibility.trail.ts +22 -0
  115. package/src/trails/no-destructured-compose.trail.ts +44 -0
  116. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  117. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  118. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  119. package/src/trails/no-native-error-result.trail.ts +18 -0
  120. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  121. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  122. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  123. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  124. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  125. package/src/trails/no-top-level-surface.trail.ts +43 -0
  126. package/src/trails/on-references-exist.trail.ts +21 -0
  127. package/src/trails/orphaned-signal.trail.ts +36 -0
  128. package/src/trails/owner-projection-parity.trail.ts +26 -0
  129. package/src/trails/pending-force.trail.ts +21 -0
  130. package/src/trails/permit-governance.trail.ts +51 -0
  131. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  132. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  133. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  134. package/src/trails/public-output-schema.trail.ts +55 -0
  135. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  136. package/src/trails/read-intent-fires.trail.ts +20 -0
  137. package/src/trails/reference-exists.trail.ts +25 -0
  138. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  139. package/src/trails/resource-declarations.trail.ts +25 -0
  140. package/src/trails/resource-exists.trail.ts +27 -0
  141. package/src/trails/resource-id-grammar.trail.ts +39 -0
  142. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  143. package/src/trails/run.ts +162 -0
  144. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  145. package/src/trails/schema.ts +198 -0
  146. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  147. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  148. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  149. package/src/trails/topo.ts +6 -0
  150. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  151. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  152. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  153. package/src/trails/valid-describe-refs.trail.ts +18 -0
  154. package/src/trails/valid-detour-contract.trail.ts +71 -0
  155. package/src/trails/version-gap.trail.ts +35 -0
  156. package/src/trails/version-pinned-compose.trail.ts +23 -0
  157. package/src/trails/version-without-examples.trail.ts +38 -0
  158. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  159. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  160. package/src/trails/webhook-route-collision.trail.ts +50 -0
  161. package/src/trails/wrap-rule.ts +214 -0
  162. package/src/workspaces.ts +199 -0
  163. package/.turbo/turbo-build.log +0 -1
  164. package/.turbo/turbo-lint.log +0 -3
  165. package/.turbo/turbo-typecheck.log +0 -1
  166. package/dist/cli.d.ts +0 -46
  167. package/dist/cli.d.ts.map +0 -1
  168. package/dist/cli.js +0 -221
  169. package/dist/cli.js.map +0 -1
  170. package/dist/drift.d.ts +0 -26
  171. package/dist/drift.d.ts.map +0 -1
  172. package/dist/drift.js +0 -27
  173. package/dist/drift.js.map +0 -1
  174. package/dist/formatters.d.ts +0 -29
  175. package/dist/formatters.d.ts.map +0 -1
  176. package/dist/formatters.js +0 -87
  177. package/dist/formatters.js.map +0 -1
  178. package/dist/index.d.ts +0 -26
  179. package/dist/index.d.ts.map +0 -1
  180. package/dist/index.js +0 -26
  181. package/dist/index.js.map +0 -1
  182. package/dist/rules/ast.d.ts +0 -41
  183. package/dist/rules/ast.d.ts.map +0 -1
  184. package/dist/rules/ast.js +0 -163
  185. package/dist/rules/ast.js.map +0 -1
  186. package/dist/rules/context-no-surface-types.d.ts +0 -12
  187. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  188. package/dist/rules/context-no-surface-types.js +0 -96
  189. package/dist/rules/context-no-surface-types.js.map +0 -1
  190. package/dist/rules/implementation-returns-result.d.ts +0 -13
  191. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  192. package/dist/rules/implementation-returns-result.js +0 -277
  193. package/dist/rules/implementation-returns-result.js.map +0 -1
  194. package/dist/rules/index.d.ts +0 -22
  195. package/dist/rules/index.d.ts.map +0 -1
  196. package/dist/rules/index.js +0 -41
  197. package/dist/rules/index.js.map +0 -1
  198. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  199. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  200. package/dist/rules/no-direct-impl-in-route.js +0 -46
  201. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  202. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  203. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  204. package/dist/rules/no-direct-implementation-call.js +0 -39
  205. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  206. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  207. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  208. package/dist/rules/no-sync-result-assumption.js +0 -98
  209. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  210. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  211. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  212. package/dist/rules/no-throw-in-detour-target.js +0 -87
  213. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  214. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  215. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  216. package/dist/rules/no-throw-in-implementation.js +0 -34
  217. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  218. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  219. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  220. package/dist/rules/prefer-schema-inference.js +0 -86
  221. package/dist/rules/prefer-schema-inference.js.map +0 -1
  222. package/dist/rules/scan.d.ts +0 -8
  223. package/dist/rules/scan.d.ts.map +0 -1
  224. package/dist/rules/scan.js +0 -32
  225. package/dist/rules/scan.js.map +0 -1
  226. package/dist/rules/specs.d.ts +0 -29
  227. package/dist/rules/specs.d.ts.map +0 -1
  228. package/dist/rules/specs.js +0 -192
  229. package/dist/rules/specs.js.map +0 -1
  230. package/dist/rules/structure.d.ts +0 -13
  231. package/dist/rules/structure.d.ts.map +0 -1
  232. package/dist/rules/structure.js +0 -142
  233. package/dist/rules/structure.js.map +0 -1
  234. package/dist/rules/types.d.ts +0 -52
  235. package/dist/rules/types.d.ts.map +0 -1
  236. package/dist/rules/types.js +0 -2
  237. package/dist/rules/types.js.map +0 -1
  238. package/dist/rules/valid-describe-refs.d.ts +0 -7
  239. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  240. package/dist/rules/valid-describe-refs.js +0 -51
  241. package/dist/rules/valid-describe-refs.js.map +0 -1
  242. package/dist/rules/valid-detour-refs.d.ts +0 -6
  243. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  244. package/dist/rules/valid-detour-refs.js +0 -116
  245. package/dist/rules/valid-detour-refs.js.map +0 -1
  246. package/src/__tests__/cli.test.ts +0 -198
  247. package/src/__tests__/drift.test.ts +0 -74
  248. package/src/__tests__/formatters.test.ts +0 -157
  249. package/src/__tests__/implementation-returns-result.test.ts +0 -128
  250. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  251. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  252. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  253. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  254. package/src/__tests__/rules.test.ts +0 -215
  255. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  256. package/src/rules/no-direct-impl-in-route.ts +0 -77
  257. package/src/rules/no-throw-in-detour-target.ts +0 -150
  258. package/src/rules/valid-detour-refs.ts +0 -187
  259. package/tsconfig.json +0 -9
  260. 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 './ast.js';
17
+ import type { AstNode } from './ast.js';
9
18
  import type { WardenDiagnostic, WardenRule } from './types.js';
10
19
 
11
20
  const SURFACE_MODULES = new Set([
@@ -29,17 +38,72 @@ 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 in `./ast.ts` exposes no abort mechanism, so
86
+ * the full tree is still traversed once a match is seen. Acceptable: `walk`
87
+ * is cheap and this rule only runs on files that already matched a path
88
+ * filter upstream.
89
+ */
90
+ const hasTrailCall = (ast: AstNode): boolean => {
91
+ let found = false;
92
+ walk(ast, (node) => {
93
+ if (found || node.type !== 'CallExpression') {
94
+ return;
95
+ }
96
+ const callee = getNodeCallee(node);
97
+ if (!callee) {
98
+ return;
99
+ }
100
+ if (isBareTrailCallee(callee) || isNamespacedTrailCallee(callee)) {
101
+ found = true;
102
+ }
103
+ });
104
+ return found;
105
+ };
106
+
43
107
  const makeDiag = (
44
108
  filePath: string,
45
109
  sourceCode: string,
@@ -92,7 +156,7 @@ const checkSpecifiersForSurfaceTypes = (
92
156
  filePath,
93
157
  sourceCode,
94
158
  node,
95
- `Do not import surface type "${typeName}" in trail implementation files.`
159
+ `Do not import surface type "${typeName}" in trail files.`
96
160
  );
97
161
  };
98
162
 
@@ -111,7 +175,7 @@ const classifyImport = (
111
175
  filePath,
112
176
  sourceCode,
113
177
  node,
114
- `Do not import from surface module "${moduleName}" in trail implementation files.`
178
+ `Do not import from surface module "${moduleName}" in trail files.`
115
179
  );
116
180
  }
117
181
 
@@ -119,18 +183,17 @@ const classifyImport = (
119
183
  };
120
184
 
121
185
  /**
122
- * Detects imports of surface-specific types in trail implementation files.
186
+ * Detects imports of surface-specific types in trail files.
123
187
  */
124
188
  export const contextNoSurfaceTypes: WardenRule = {
125
189
  check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
126
- if (!/\b(?:trail|hike)\s*\(/.test(sourceCode)) {
127
- return [];
128
- }
129
-
130
190
  const ast = parse(filePath, sourceCode);
131
191
  if (!ast) {
132
192
  return [];
133
193
  }
194
+ if (!hasTrailCall(ast)) {
195
+ return [];
196
+ }
134
197
 
135
198
  const diagnostics: WardenDiagnostic[] = [];
136
199
  walk(ast, (node) => {
@@ -143,7 +206,7 @@ export const contextNoSurfaceTypes: WardenRule = {
143
206
  return diagnostics;
144
207
  },
145
208
  description:
146
- 'Disallow surface-specific type imports (Request, Response, McpSession, etc.) in trail implementation files.',
209
+ 'Disallow surface-specific type imports (Request, Response, McpSession, etc.) in trail files.',
147
210
  name: 'context-no-surface-types',
148
211
 
149
212
  severity: 'error',
@@ -0,0 +1,251 @@
1
+ import {
2
+ buildUserNamespaceContext,
3
+ collectContourDefinitionIds,
4
+ collectImportAliasMap,
5
+ collectNamedContourIds,
6
+ deriveContourIdentifierName,
7
+ extractFirstStringArg,
8
+ findConfigProperty,
9
+ findTrailDefinitions,
10
+ getNodeCallee,
11
+ getNodeObject,
12
+ getNodeProperty,
13
+ identifierName,
14
+ isMemberAccessNonComputed,
15
+ isUserNamespaceReceiverAllowed,
16
+ offsetToLine,
17
+ parse,
18
+ } from './ast.js';
19
+ import type { AstNode, TrailDefinition, UserNamespaceContext } from './ast.js';
20
+ import { mergeKnownContourIds } from './contour-ids.js';
21
+ import { isTestFile } from './scan.js';
22
+ import type {
23
+ ProjectAwareWardenRule,
24
+ ProjectContext,
25
+ WardenDiagnostic,
26
+ } from './types.js';
27
+
28
+ const isContourCall = (node: AstNode): boolean =>
29
+ node.type === 'CallExpression' &&
30
+ identifierName(getNodeCallee(node)) === 'contour';
31
+
32
+ const getContourElements = (config: AstNode): readonly AstNode[] => {
33
+ const contoursProp = findConfigProperty(config, 'contours');
34
+ if (!contoursProp) {
35
+ return [];
36
+ }
37
+
38
+ const arrayNode = contoursProp.value;
39
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
40
+ return [];
41
+ }
42
+
43
+ const elements = (arrayNode as AstNode)['elements'] as
44
+ | readonly AstNode[]
45
+ | undefined;
46
+ return elements ?? [];
47
+ };
48
+
49
+ /**
50
+ * Resolve `contours.user` to its contour name. When `userNamespace` carries a
51
+ * scope-aware `safeMemberStarts` set, the member access must appear in it —
52
+ * rejecting cases where `contours` is shadowed by a local binding such as a
53
+ * function parameter or `const contours = ...`. Without the set, falls back
54
+ * to the bare name check for backward compatibility.
55
+ */
56
+ const resolveNamespaceMemberContourName = (
57
+ element: AstNode,
58
+ userNamespace: UserNamespaceContext
59
+ ): string | null => {
60
+ if (!isMemberAccessNonComputed(element)) {
61
+ return null;
62
+ }
63
+ const object = getNodeObject(element);
64
+ const property = getNodeProperty(element);
65
+ const receiver = object ? identifierName(object) : null;
66
+ if (
67
+ !receiver ||
68
+ !isUserNamespaceReceiverAllowed(receiver, element.start, userNamespace)
69
+ ) {
70
+ return null;
71
+ }
72
+ return property ? identifierName(property) : null;
73
+ };
74
+
75
+ const resolveDeclaredContourName = (
76
+ element: AstNode,
77
+ contourIdsByName: ReadonlyMap<string, string>,
78
+ knownContourIds?: ReadonlySet<string>,
79
+ importAliases?: ReadonlyMap<string, string>,
80
+ userNamespace?: UserNamespaceContext
81
+ ): string | null => {
82
+ if (element.type === 'Identifier') {
83
+ const name = identifierName(element);
84
+ return name
85
+ ? deriveContourIdentifierName(
86
+ name,
87
+ contourIdsByName,
88
+ knownContourIds,
89
+ importAliases
90
+ )
91
+ : null;
92
+ }
93
+
94
+ if (userNamespace && userNamespace.bindings.size > 0) {
95
+ const namespaceTarget = resolveNamespaceMemberContourName(
96
+ element,
97
+ userNamespace
98
+ );
99
+ if (namespaceTarget) {
100
+ return namespaceTarget;
101
+ }
102
+ }
103
+
104
+ return isContourCall(element) ? extractFirstStringArg(element) : null;
105
+ };
106
+
107
+ const extractDeclaredContourNames = (
108
+ config: AstNode,
109
+ contourIdsByName: ReadonlyMap<string, string>,
110
+ knownContourIds?: ReadonlySet<string>,
111
+ importAliases?: ReadonlyMap<string, string>,
112
+ userNamespace?: UserNamespaceContext
113
+ ): readonly string[] => [
114
+ ...new Set(
115
+ getContourElements(config).flatMap((element) => {
116
+ const contourName = resolveDeclaredContourName(
117
+ element,
118
+ contourIdsByName,
119
+ knownContourIds,
120
+ importAliases,
121
+ userNamespace
122
+ );
123
+ return contourName ? [contourName] : [];
124
+ })
125
+ ),
126
+ ];
127
+
128
+ const buildMissingContourDiagnostic = (
129
+ trailId: string,
130
+ contourName: string,
131
+ filePath: string,
132
+ line: number
133
+ ): WardenDiagnostic => ({
134
+ filePath,
135
+ line,
136
+ message: `Trail "${trailId}" declares contour "${contourName}" which is not defined in the project. Define it with contour('${contourName}', ...) and include it in the topo, or fix the contours entry if this is a typo.`,
137
+ rule: 'contour-exists',
138
+ severity: 'error',
139
+ });
140
+
141
+ const buildDiagnosticsForDefinition = (
142
+ definition: TrailDefinition,
143
+ sourceCode: string,
144
+ filePath: string,
145
+ knownContourIds: ReadonlySet<string>,
146
+ contourIdsByName: ReadonlyMap<string, string>,
147
+ importAliases: ReadonlyMap<string, string>,
148
+ userNamespace: UserNamespaceContext
149
+ ): readonly WardenDiagnostic[] => {
150
+ if (definition.kind !== 'trail') {
151
+ return [];
152
+ }
153
+
154
+ const line = offsetToLine(sourceCode, definition.start);
155
+ return extractDeclaredContourNames(
156
+ definition.config,
157
+ contourIdsByName,
158
+ knownContourIds,
159
+ importAliases,
160
+ userNamespace
161
+ ).flatMap((contourName) =>
162
+ knownContourIds.has(contourName)
163
+ ? []
164
+ : [
165
+ buildMissingContourDiagnostic(
166
+ definition.id,
167
+ contourName,
168
+ filePath,
169
+ line
170
+ ),
171
+ ]
172
+ );
173
+ };
174
+
175
+ const buildContourDiagnostics = (
176
+ ast: AstNode,
177
+ sourceCode: string,
178
+ filePath: string,
179
+ knownContourIds: ReadonlySet<string>
180
+ ): readonly WardenDiagnostic[] => {
181
+ const contourIdsByName = collectNamedContourIds(ast);
182
+ const importAliases = collectImportAliasMap(ast);
183
+ const userNamespace = buildUserNamespaceContext(ast);
184
+
185
+ return findTrailDefinitions(ast).flatMap((definition) =>
186
+ buildDiagnosticsForDefinition(
187
+ definition,
188
+ sourceCode,
189
+ filePath,
190
+ knownContourIds,
191
+ contourIdsByName,
192
+ importAliases,
193
+ userNamespace
194
+ )
195
+ );
196
+ };
197
+
198
+ const checkContourDeclarations = (
199
+ ast: AstNode,
200
+ sourceCode: string,
201
+ filePath: string,
202
+ knownContourIds: ReadonlySet<string>
203
+ ): readonly WardenDiagnostic[] => {
204
+ if (isTestFile(filePath)) {
205
+ return [];
206
+ }
207
+
208
+ return buildContourDiagnostics(ast, sourceCode, filePath, knownContourIds);
209
+ };
210
+
211
+ /**
212
+ * Checks that every contour declared in a trail `contours` array resolves to a
213
+ * known contour definition.
214
+ */
215
+ export const contourExists: ProjectAwareWardenRule = {
216
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
217
+ const ast = parse(filePath, sourceCode);
218
+ if (!ast) {
219
+ return [];
220
+ }
221
+
222
+ return checkContourDeclarations(
223
+ ast,
224
+ sourceCode,
225
+ filePath,
226
+ collectContourDefinitionIds(ast)
227
+ );
228
+ },
229
+ checkWithContext(
230
+ sourceCode: string,
231
+ filePath: string,
232
+ context: ProjectContext
233
+ ): readonly WardenDiagnostic[] {
234
+ const ast = parse(filePath, sourceCode);
235
+ if (!ast) {
236
+ return [];
237
+ }
238
+
239
+ const localContourIds = collectContourDefinitionIds(ast);
240
+ return checkContourDeclarations(
241
+ ast,
242
+ sourceCode,
243
+ filePath,
244
+ mergeKnownContourIds(localContourIds, context.knownContourIds)
245
+ );
246
+ },
247
+ description:
248
+ 'Ensure every contour declared on a trail resolves to a known contour definition.',
249
+ name: 'contour-exists',
250
+ severity: 'error',
251
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Merge a file's locally-defined contour IDs with the project-wide set.
3
+ *
4
+ * Rules that run with a `ProjectContext` need to treat both local and
5
+ * project-wide contour definitions as "known" so that declarations and
6
+ * references resolve correctly. When no project context is available — e.g.
7
+ * single-file lint runs via `check` — the local set is returned as-is.
8
+ */
9
+ export const mergeKnownContourIds = (
10
+ localContourIds: ReadonlySet<string>,
11
+ projectContourIds?: ReadonlySet<string>
12
+ ): ReadonlySet<string> =>
13
+ projectContourIds
14
+ ? new Set([...projectContourIds, ...localContourIds])
15
+ : localContourIds;
@@ -0,0 +1,161 @@
1
+ import {
2
+ collectComposeTargetTrailIds,
3
+ findConfigProperty,
4
+ findTrailDefinitions,
5
+ getNodeValue,
6
+ getStringValue,
7
+ isStringLiteral,
8
+ offsetToLine,
9
+ parse,
10
+ } from './ast.js';
11
+ import type { AstNode } from './ast.js';
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
+ };