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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +837 -13
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +27 -6
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +1570 -105
  7. package/src/command.ts +986 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +233 -23
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +78 -13
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +247 -15
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/resolve.ts +531 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/captured-kernel.ts +375 -0
  20. package/src/rules/circular-refs.ts +150 -0
  21. package/src/rules/cli-command-route-coherence.ts +177 -0
  22. package/src/rules/composes-declarations.ts +839 -0
  23. package/src/rules/context-no-surface-types.ts +79 -15
  24. package/src/rules/dead-internal-trail.ts +161 -0
  25. package/src/rules/dead-public-trail.ts +258 -0
  26. package/src/rules/draft-file-marking.ts +155 -0
  27. package/src/rules/draft-visible-debt.ts +82 -0
  28. package/src/rules/duplicate-exported-symbol.ts +211 -0
  29. package/src/rules/duplicate-public-contract.ts +137 -0
  30. package/src/rules/entity-exists.ts +254 -0
  31. package/src/rules/entity-ids.ts +15 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +740 -0
  35. package/src/rules/governed-symbol-residue.ts +438 -0
  36. package/src/rules/implementation-returns-result.ts +1409 -166
  37. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  38. package/src/rules/incomplete-crud.ts +583 -0
  39. package/src/rules/index.ts +277 -10
  40. package/src/rules/intent-propagation.ts +125 -0
  41. package/src/rules/layer-field-name-drift.ts +102 -0
  42. package/src/rules/library-projection-coherence.ts +100 -0
  43. package/src/rules/metadata.ts +871 -0
  44. package/src/rules/missing-reconcile.ts +97 -0
  45. package/src/rules/missing-visibility.ts +111 -0
  46. package/src/rules/no-destructured-compose.ts +196 -0
  47. package/src/rules/no-dev-permit-in-source.ts +99 -0
  48. package/src/rules/no-direct-implementation-call.ts +12 -7
  49. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  50. package/src/rules/no-legacy-layer-imports.ts +211 -0
  51. package/src/rules/no-native-error-result.ts +118 -0
  52. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  53. package/src/rules/no-retired-cross-vocabulary.ts +204 -0
  54. package/src/rules/no-sync-result-assumption.ts +1141 -98
  55. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  56. package/src/rules/no-throw-in-implementation.ts +15 -8
  57. package/src/rules/no-top-level-surface.ts +371 -0
  58. package/src/rules/on-references-exist.ts +194 -0
  59. package/src/rules/orphaned-signal.ts +149 -0
  60. package/src/rules/owner-projection-parity.ts +143 -0
  61. package/src/rules/permit-governance.ts +25 -0
  62. package/src/rules/public-export-example-coverage.ts +573 -0
  63. package/src/rules/public-internal-deep-imports.ts +456 -0
  64. package/src/rules/public-output-schema.ts +29 -0
  65. package/src/rules/public-union-output-discriminants.ts +150 -0
  66. package/src/rules/read-intent-fires.ts +188 -0
  67. package/src/rules/reference-exists.ts +97 -0
  68. package/src/rules/registry-names.ts +167 -0
  69. package/src/rules/resolved-import-boundary.ts +146 -0
  70. package/src/rules/resource-declarations.ts +697 -0
  71. package/src/rules/resource-exists.ts +181 -0
  72. package/src/rules/resource-id-grammar.ts +65 -0
  73. package/src/rules/resource-mock-coverage.ts +115 -0
  74. package/src/rules/retired-vocabulary.ts +633 -0
  75. package/src/rules/scan.ts +38 -25
  76. package/src/rules/scheduled-destroy-intent.ts +44 -0
  77. package/src/rules/signal-graph-coaching.ts +220 -0
  78. package/src/rules/source/composition.ts +165 -0
  79. package/src/rules/source/drafts.ts +164 -0
  80. package/src/rules/source/entities.ts +618 -0
  81. package/src/rules/source/pragmas.ts +45 -0
  82. package/src/rules/source/resources.ts +64 -0
  83. package/src/rules/source/signals.ts +397 -0
  84. package/src/rules/source/stores.ts +310 -0
  85. package/src/rules/specs.ts +9 -5
  86. package/src/rules/static-resource-accessor-preference.ts +653 -0
  87. package/src/rules/surface-overlay-coherence.ts +262 -0
  88. package/src/rules/surface-trailhead-coherence.ts +366 -0
  89. package/src/rules/trail-fork-coaching.ts +625 -0
  90. package/src/rules/trail-versioning-source.ts +1076 -0
  91. package/src/rules/trail-versioning-topo.ts +172 -0
  92. package/src/rules/trailhead-override-divergence.ts +356 -0
  93. package/src/rules/types.ts +354 -8
  94. package/src/rules/unmaterialized-activation-source.ts +85 -0
  95. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  96. package/src/rules/valid-describe-refs.ts +162 -32
  97. package/src/rules/valid-detour-contract.ts +78 -0
  98. package/src/rules/warden-export-symmetry.ts +540 -0
  99. package/src/rules/warden-rules-use-ast.ts +1109 -0
  100. package/src/rules/webhook-route-collision.ts +306 -0
  101. package/src/trails/activation-orphan.trail.ts +84 -0
  102. package/src/trails/captured-kernel.trail.ts +108 -0
  103. package/src/trails/circular-refs.trail.ts +29 -0
  104. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  105. package/src/trails/composes-declarations.trail.ts +22 -0
  106. package/src/trails/context-no-surface-types.trail.ts +21 -0
  107. package/src/trails/dead-internal-trail.trail.ts +26 -0
  108. package/src/trails/dead-public-trail.trail.ts +31 -0
  109. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  110. package/src/trails/draft-file-marking.trail.ts +16 -0
  111. package/src/trails/draft-visible-debt.trail.ts +16 -0
  112. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  113. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  114. package/src/trails/entity-exists.trail.ts +21 -0
  115. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  116. package/src/trails/example-valid.trail.ts +25 -0
  117. package/src/trails/fires-declarations.trail.ts +23 -0
  118. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  119. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  120. package/src/trails/implementation-returns-result.trail.ts +20 -0
  121. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  122. package/src/trails/incomplete-crud.trail.ts +39 -0
  123. package/src/trails/index.ts +89 -0
  124. package/src/trails/intent-propagation.trail.ts +30 -0
  125. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  126. package/src/trails/library-projection-coherence.trail.ts +43 -0
  127. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  128. package/src/trails/missing-reconcile.trail.ts +33 -0
  129. package/src/trails/missing-visibility.trail.ts +22 -0
  130. package/src/trails/no-destructured-compose.trail.ts +44 -0
  131. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  132. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  133. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  134. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  135. package/src/trails/no-native-error-result.trail.ts +18 -0
  136. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  137. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  138. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  139. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  140. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  141. package/src/trails/no-top-level-surface.trail.ts +43 -0
  142. package/src/trails/on-references-exist.trail.ts +21 -0
  143. package/src/trails/orphaned-signal.trail.ts +36 -0
  144. package/src/trails/owner-projection-parity.trail.ts +26 -0
  145. package/src/trails/pending-force.trail.ts +21 -0
  146. package/src/trails/permit-governance.trail.ts +51 -0
  147. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  148. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  149. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  150. package/src/trails/public-output-schema.trail.ts +55 -0
  151. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  152. package/src/trails/read-intent-fires.trail.ts +20 -0
  153. package/src/trails/reference-exists.trail.ts +25 -0
  154. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  155. package/src/trails/resource-declarations.trail.ts +25 -0
  156. package/src/trails/resource-exists.trail.ts +27 -0
  157. package/src/trails/resource-id-grammar.trail.ts +39 -0
  158. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  159. package/src/trails/run.ts +160 -0
  160. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  161. package/src/trails/schema.ts +237 -0
  162. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  163. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  164. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  165. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  166. package/src/trails/topo.ts +6 -0
  167. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  168. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  169. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  170. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  171. package/src/trails/valid-describe-refs.trail.ts +18 -0
  172. package/src/trails/valid-detour-contract.trail.ts +71 -0
  173. package/src/trails/version-gap.trail.ts +35 -0
  174. package/src/trails/version-pinned-compose.trail.ts +23 -0
  175. package/src/trails/version-without-examples.trail.ts +38 -0
  176. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  177. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  178. package/src/trails/webhook-route-collision.trail.ts +50 -0
  179. package/src/trails/wrap-rule.ts +224 -0
  180. package/src/workspaces.ts +199 -0
  181. package/.turbo/turbo-build.log +0 -1
  182. package/.turbo/turbo-lint.log +0 -3
  183. package/.turbo/turbo-typecheck.log +0 -1
  184. package/dist/cli.d.ts +0 -46
  185. package/dist/cli.d.ts.map +0 -1
  186. package/dist/cli.js +0 -218
  187. package/dist/cli.js.map +0 -1
  188. package/dist/drift.d.ts +0 -26
  189. package/dist/drift.d.ts.map +0 -1
  190. package/dist/drift.js +0 -27
  191. package/dist/drift.js.map +0 -1
  192. package/dist/formatters.d.ts +0 -29
  193. package/dist/formatters.d.ts.map +0 -1
  194. package/dist/formatters.js +0 -87
  195. package/dist/formatters.js.map +0 -1
  196. package/dist/index.d.ts +0 -26
  197. package/dist/index.d.ts.map +0 -1
  198. package/dist/index.js +0 -26
  199. package/dist/index.js.map +0 -1
  200. package/dist/rules/ast.d.ts +0 -41
  201. package/dist/rules/ast.d.ts.map +0 -1
  202. package/dist/rules/ast.js +0 -161
  203. package/dist/rules/ast.js.map +0 -1
  204. package/dist/rules/context-no-surface-types.d.ts +0 -12
  205. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  206. package/dist/rules/context-no-surface-types.js +0 -96
  207. package/dist/rules/context-no-surface-types.js.map +0 -1
  208. package/dist/rules/implementation-returns-result.d.ts +0 -13
  209. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  210. package/dist/rules/implementation-returns-result.js +0 -277
  211. package/dist/rules/implementation-returns-result.js.map +0 -1
  212. package/dist/rules/index.d.ts +0 -15
  213. package/dist/rules/index.d.ts.map +0 -1
  214. package/dist/rules/index.js +0 -34
  215. package/dist/rules/index.js.map +0 -1
  216. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  217. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  218. package/dist/rules/no-direct-impl-in-route.js +0 -47
  219. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  220. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  221. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  222. package/dist/rules/no-direct-implementation-call.js +0 -39
  223. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  224. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  225. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  226. package/dist/rules/no-sync-result-assumption.js +0 -98
  227. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  228. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  229. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  230. package/dist/rules/no-throw-in-detour-target.js +0 -87
  231. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  232. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  233. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  234. package/dist/rules/no-throw-in-implementation.js +0 -34
  235. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  236. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  237. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  238. package/dist/rules/prefer-schema-inference.js +0 -86
  239. package/dist/rules/prefer-schema-inference.js.map +0 -1
  240. package/dist/rules/scan.d.ts +0 -8
  241. package/dist/rules/scan.d.ts.map +0 -1
  242. package/dist/rules/scan.js +0 -32
  243. package/dist/rules/scan.js.map +0 -1
  244. package/dist/rules/specs.d.ts +0 -29
  245. package/dist/rules/specs.d.ts.map +0 -1
  246. package/dist/rules/specs.js +0 -192
  247. package/dist/rules/specs.js.map +0 -1
  248. package/dist/rules/structure.d.ts +0 -13
  249. package/dist/rules/structure.d.ts.map +0 -1
  250. package/dist/rules/structure.js +0 -142
  251. package/dist/rules/structure.js.map +0 -1
  252. package/dist/rules/types.d.ts +0 -52
  253. package/dist/rules/types.d.ts.map +0 -1
  254. package/dist/rules/types.js +0 -2
  255. package/dist/rules/types.js.map +0 -1
  256. package/dist/rules/valid-describe-refs.d.ts +0 -7
  257. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  258. package/dist/rules/valid-describe-refs.js +0 -51
  259. package/dist/rules/valid-describe-refs.js.map +0 -1
  260. package/dist/rules/valid-detour-refs.d.ts +0 -6
  261. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  262. package/dist/rules/valid-detour-refs.js +0 -116
  263. package/dist/rules/valid-detour-refs.js.map +0 -1
  264. package/src/__tests__/cli.test.ts +0 -198
  265. package/src/__tests__/drift.test.ts +0 -74
  266. package/src/__tests__/formatters.test.ts +0 -157
  267. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  268. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  269. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  270. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  271. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  272. package/src/__tests__/rules.test.ts +0 -227
  273. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  274. package/src/rules/ast.ts +0 -213
  275. package/src/rules/no-direct-impl-in-route.ts +0 -81
  276. package/src/rules/no-throw-in-detour-target.ts +0 -150
  277. package/src/rules/valid-detour-refs.ts +0 -187
  278. package/tsconfig.json +0 -9
  279. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,1109 @@
1
+ /**
2
+ * Self-governance rule: warden rules must inspect the AST via the helpers in
3
+ * `../source/*` helpers rather than regex-scanning raw source text. Raw-text scans
4
+ * produce false positives on string literals, template payloads, and
5
+ * docstrings — see TRL-335 and ADR-0036.
6
+ *
7
+ * Three detection families are enforced:
8
+ *
9
+ * 1. `rawScanSite` — string methods on a raw-source identifier, e.g.
10
+ * `sourceCode.split(/\n/)`, `rawText.match(...)`, `text.replace(...)`.
11
+ * 2. `regexScanSite` — regex-receiver methods consuming a raw-source
12
+ * argument, e.g. `/re/.test(sourceCode)`, `new RegExp(...).exec(text)`.
13
+ * 3. `regexConstructionSite` — constructing a regex directly from a raw
14
+ * source identifier, e.g. `new RegExp(sourceCode)`, `RegExp(rawText, 'g')`.
15
+ * Interpolating raw source into a regex constructor is the same class of
16
+ * bug as scanning with one — see TRL-345.
17
+ * 4. `rawNodeFieldCastSite` — asserting AST nodes through
18
+ * `as unknown as { field?: ... }` where a typed accessor exists on
19
+ * `../source/*`.
20
+ *
21
+ * This rule is path-anchored to this package's own `src/rules/` directory so
22
+ * it never fires against a consumer repo that happens to share the same
23
+ * folder layout. Support modules such as `types.ts`, `index.ts`,
24
+ * `registry-names.ts`, and anything under `__tests__` are excluded.
25
+ */
26
+ import { basename as pathBasename, dirname, resolve, sep } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ import {
30
+ getNodeArgument,
31
+ getNodeArguments,
32
+ getNodeBodyNode,
33
+ getNodeBodyStatements,
34
+ getNodeCallee,
35
+ getNodeComputed,
36
+ getNodeDeclarations,
37
+ getNodeElements,
38
+ getNodeExpression,
39
+ getNodeId,
40
+ getNodeKey,
41
+ getNodeKind,
42
+ getNodeLeft,
43
+ getNodeName,
44
+ getNodeObject,
45
+ getNodeParam,
46
+ getNodeParams,
47
+ getNodeProperties,
48
+ getNodeProperty,
49
+ getNodeTypeAnnotation,
50
+ getNodeValueNode,
51
+ getStringValue,
52
+ isAstNode,
53
+ offsetToLine,
54
+ parse,
55
+ walk,
56
+ } from '@ontrails/source';
57
+ import type { AstNode } from '@ontrails/source';
58
+ import type { WardenDiagnostic, WardenRule } from './types.js';
59
+
60
+ const RULE_NAME = 'warden-rules-use-ast';
61
+
62
+ /**
63
+ * Absolute path to this package's rules directory, resolved from the rule's
64
+ * own module URL. Anchoring to the real on-disk location prevents the rule
65
+ * from firing against a foreign `packages/warden/src/rules/` in a consumer
66
+ * repository that happens to share the same folder structure.
67
+ *
68
+ * Dist-layout safeguard: when this module is bundled/transpiled to `dist/`
69
+ * (e.g. `packages/warden/dist/rules/warden-rules-use-ast.js`), the files
70
+ * being linted still live under `src/rules/`. A strict equality check
71
+ * against only the dist directory would cause the rule to silently emit
72
+ * zero diagnostics — a silent no-op. To keep the anchor robust, we compute
73
+ * a source-equivalent dir by substituting `/dist/` with `/src/` on the
74
+ * resolved path and accept either. This preserves the anti-false-positive
75
+ * guarantee from TRL-341 (we still require an exact directory match, not a
76
+ * suffix match) while surviving a future bundling change.
77
+ */
78
+ const SELF_MODULE_DIR = resolve(dirname(fileURLToPath(import.meta.url)));
79
+
80
+ /**
81
+ * Replace only the LAST occurrence of `/dist/` with `/src/`. A blanket
82
+ * `replaceAll` over-substitutes on paths that contain other `/dist/`
83
+ * segments higher up (e.g. `/home/runner/dist-artifacts/warden/dist/rules/`
84
+ * would incorrectly become `/home/runner/src-artifacts/warden/src/rules/`,
85
+ * a nonexistent directory — silently defeating the rule).
86
+ *
87
+ * Exported for unit testing. Not part of the public rule API.
88
+ */
89
+ export const replaceLastDistSegmentWithSrc = (path: string): string => {
90
+ const distSegment = `${sep}dist${sep}`;
91
+ const srcSegment = `${sep}src${sep}`;
92
+ const lastIdx = path.lastIndexOf(distSegment);
93
+ if (lastIdx === -1) {
94
+ return path;
95
+ }
96
+ return (
97
+ path.slice(0, lastIdx) +
98
+ srcSegment +
99
+ path.slice(lastIdx + distSegment.length)
100
+ );
101
+ };
102
+
103
+ const SELF_RULES_DIRS: ReadonlySet<string> = new Set(
104
+ SELF_MODULE_DIR.includes(`${sep}dist${sep}`)
105
+ ? [SELF_MODULE_DIR, replaceLastDistSegmentWithSrc(SELF_MODULE_DIR)]
106
+ : [SELF_MODULE_DIR]
107
+ );
108
+
109
+ /**
110
+ * Stems of files in `src/rules/` (and their bundled `dist/rules/` twins) that
111
+ * are not themselves Warden rules and therefore must not be checked. These
112
+ * are support modules without a `check()` function.
113
+ */
114
+ const EXCLUDED_STEMS: readonly string[] = [
115
+ 'index',
116
+ 'registry-names',
117
+ 'scan',
118
+ 'specs',
119
+ 'structure',
120
+ 'types',
121
+ ];
122
+
123
+ /**
124
+ * Both `.ts` (source layout) and `.js` (dist layout) basenames must be
125
+ * excluded so the rule stays silent when pointed at a bundled tree.
126
+ */
127
+ const EXCLUDED_BASENAMES: ReadonlySet<string> = new Set(
128
+ EXCLUDED_STEMS.flatMap((stem) => [`${stem}.ts`, `${stem}.js`])
129
+ );
130
+
131
+ const isTargetFile = (filePath: string): boolean => {
132
+ const absolute = resolve(filePath);
133
+ if (!SELF_RULES_DIRS.has(dirname(absolute))) {
134
+ return false;
135
+ }
136
+ const basename = pathBasename(absolute);
137
+ if (EXCLUDED_BASENAMES.has(basename)) {
138
+ return false;
139
+ }
140
+ if (basename.endsWith('.test.ts') || basename.endsWith('.test.js')) {
141
+ return false;
142
+ }
143
+ return true;
144
+ };
145
+
146
+ /**
147
+ * Names of the WardenRule methods that receive raw source text as their
148
+ * first parameter. The first parameter's *actual binding name* (not a fixed
149
+ * list of names) is what we track via scope analysis — see `buildSourceParamIndex`.
150
+ *
151
+ * `checkTopo` does not receive raw source text (it takes a `Topo`) so it is
152
+ * intentionally excluded.
153
+ */
154
+ const SOURCE_PARAM_METHOD_NAMES: ReadonlySet<string> = new Set([
155
+ 'check',
156
+ 'checkWithContext',
157
+ ]);
158
+
159
+ /**
160
+ * String methods that indicate raw-text *scanning* when called on a
161
+ * source-text identifier. Deliberately narrow: these are the patterns that
162
+ * produce false positives on string literals, template payloads, and
163
+ * docstrings — the regression TRL-333/TRL-334/TRL-335 fixed.
164
+ *
165
+ * Not flagged: `.slice`, `.substring`, `.indexOf`, `.includes`. These also
166
+ * take source text as input, but have legitimate AST-adjacent uses — e.g.
167
+ * `sourceCode.slice(node.start, node.end)` to recover a node's original
168
+ * text from an AST-resolved range, or `sourceCode.includes('marker')` as a
169
+ * fast-bail check before parsing. Flagging them would produce false
170
+ * positives on idiomatic rules.
171
+ */
172
+ const RAW_SCAN_METHODS: ReadonlySet<string> = new Set([
173
+ 'match',
174
+ 'matchAll',
175
+ 'replace',
176
+ 'replaceAll',
177
+ 'search',
178
+ 'split',
179
+ ]);
180
+
181
+ /**
182
+ * Methods on a regex receiver that consume a raw-text argument. Flagged
183
+ * when the argument is a raw-source identifier (`sourceCode`, `text`, etc.).
184
+ */
185
+ const REGEX_SCAN_METHODS: ReadonlySet<string> = new Set(['exec', 'test']);
186
+
187
+ const getIdentifierName = (node: AstNode | undefined): string | null => {
188
+ if (!node || node.type !== 'Identifier') {
189
+ return null;
190
+ }
191
+ const name = getNodeName(node);
192
+ return typeof name === 'string' ? name : null;
193
+ };
194
+
195
+ /**
196
+ * Scope-based source-param resolution. Each detector returns the
197
+ * candidate `Identifier` AST node that must resolve to the enclosing
198
+ * `check` / `checkWithContext` method's first parameter binding — i.e.
199
+ * not shadowed by any intervening `const`/`let`/`var`/param declaration.
200
+ * See `resolvesToSourceParam` for the walk.
201
+ */
202
+ interface RawScanSite {
203
+ readonly methodName: string;
204
+ readonly identifier: AstNode;
205
+ readonly identifierName: string;
206
+ readonly start: number;
207
+ }
208
+
209
+ interface MemberCallParts {
210
+ readonly object: AstNode | undefined;
211
+ readonly property: AstNode | undefined;
212
+ }
213
+
214
+ /**
215
+ * Extract the `object`/`property` of a non-computed member call, or null
216
+ * for anything else. Keeps `rawScanSite` under the max-statements budget.
217
+ */
218
+ const memberCallParts = (node: AstNode): MemberCallParts | null => {
219
+ if (node.type !== 'CallExpression') {
220
+ return null;
221
+ }
222
+ const callee = getNodeCallee(node);
223
+ if (
224
+ !callee ||
225
+ (callee.type !== 'MemberExpression' &&
226
+ callee.type !== 'StaticMemberExpression')
227
+ ) {
228
+ return null;
229
+ }
230
+ const object = getNodeObject(callee);
231
+ const property = getNodeProperty(callee);
232
+ const computed = getNodeComputed(callee);
233
+ return computed ? null : { object, property };
234
+ };
235
+
236
+ const rawScanSite = (node: AstNode): RawScanSite | null => {
237
+ const parts = memberCallParts(node);
238
+ if (!parts || !parts.object) {
239
+ return null;
240
+ }
241
+ const receiver = getIdentifierName(parts.object);
242
+ if (!receiver) {
243
+ return null;
244
+ }
245
+ const methodName = getIdentifierName(parts.property);
246
+ if (!methodName || !RAW_SCAN_METHODS.has(methodName)) {
247
+ return null;
248
+ }
249
+ return {
250
+ identifier: parts.object,
251
+ identifierName: receiver,
252
+ methodName,
253
+ start: node.start,
254
+ };
255
+ };
256
+
257
+ /**
258
+ * True when `node` is a regex-producing expression: a regex literal
259
+ * (`/foo/`), `new RegExp(...)`, or a plain `RegExp(...)` call.
260
+ */
261
+ const isRegexProducer = (node: AstNode | undefined): boolean => {
262
+ if (!node) {
263
+ return false;
264
+ }
265
+ if (node.type === 'Literal' && 'regex' in node && node['regex']) {
266
+ return true;
267
+ }
268
+ if (node.type === 'RegExpLiteral') {
269
+ return true;
270
+ }
271
+ if (node.type === 'NewExpression' || node.type === 'CallExpression') {
272
+ const callee = getNodeCallee(node);
273
+ return getIdentifierName(callee) === 'RegExp';
274
+ }
275
+ return false;
276
+ };
277
+
278
+ /**
279
+ * First identifier argument of a call expression, or null. The returned
280
+ * identifier must still resolve to a tracked source-param binding (see
281
+ * `resolvesToSourceParam`) before a diagnostic is emitted — this pre-filter
282
+ * only narrows the candidate arg.
283
+ */
284
+ const firstIdentifierArgument = (
285
+ node: AstNode
286
+ ): { identifier: AstNode; name: string } | null => {
287
+ const args = getNodeArguments(node);
288
+ if (!args) {
289
+ return null;
290
+ }
291
+ for (const arg of args) {
292
+ const name = getIdentifierName(arg);
293
+ if (name) {
294
+ return { identifier: arg, name };
295
+ }
296
+ }
297
+ return null;
298
+ };
299
+
300
+ const regexScanMethodName = (parts: MemberCallParts): string | null => {
301
+ if (!isRegexProducer(parts.object)) {
302
+ return null;
303
+ }
304
+ const methodName = getIdentifierName(parts.property);
305
+ if (!methodName || !REGEX_SCAN_METHODS.has(methodName)) {
306
+ return null;
307
+ }
308
+ return methodName;
309
+ };
310
+
311
+ /**
312
+ * Detects `/regex/.test(sourceCode)`, `new RegExp(...).exec(text)`, and
313
+ * similar regex-receiver calls that consume a raw-source identifier.
314
+ */
315
+ const regexScanSite = (node: AstNode): RawScanSite | null => {
316
+ const parts = memberCallParts(node);
317
+ if (!parts) {
318
+ return null;
319
+ }
320
+ const methodName = regexScanMethodName(parts);
321
+ if (!methodName) {
322
+ return null;
323
+ }
324
+ const arg = firstIdentifierArgument(node);
325
+ if (!arg) {
326
+ return null;
327
+ }
328
+ return {
329
+ identifier: arg.identifier,
330
+ identifierName: arg.name,
331
+ methodName,
332
+ start: node.start,
333
+ };
334
+ };
335
+
336
+ interface RegexConstructionSite {
337
+ readonly kind: 'new' | 'call';
338
+ readonly identifier: AstNode;
339
+ readonly identifierName: string;
340
+ readonly start: number;
341
+ }
342
+
343
+ /**
344
+ * Detects `new RegExp(sourceCode)` / `RegExp(rawText, 'g')` — constructing a
345
+ * regex from raw source text. Same anti-pattern family as
346
+ * `sourceCode.match(...)` and `/re/.test(sourceCode)`: raw source fed into a
347
+ * scanner. Fires when the callee is an `Identifier` named `RegExp` and at
348
+ * least one argument is an identifier that resolves, via scope analysis, to
349
+ * the enclosing `check` / `checkWithContext` method's first parameter.
350
+ */
351
+ const regexConstructionSite = (node: AstNode): RegexConstructionSite | null => {
352
+ if (node.type !== 'NewExpression' && node.type !== 'CallExpression') {
353
+ return null;
354
+ }
355
+ const callee = getNodeCallee(node);
356
+ if (getIdentifierName(callee) !== 'RegExp') {
357
+ return null;
358
+ }
359
+ const arg = firstIdentifierArgument(node);
360
+ if (!arg) {
361
+ return null;
362
+ }
363
+ return {
364
+ identifier: arg.identifier,
365
+ identifierName: arg.name,
366
+ kind: node.type === 'NewExpression' ? 'new' : 'call',
367
+ start: node.start,
368
+ };
369
+ };
370
+
371
+ const DIAGNOSTIC_ADVICE =
372
+ 'Warden rules must inspect source code via @ontrails/source helpers, not regex-scan raw source text. ' +
373
+ 'Use findStringLiterals, findTrailDefinitions, findConfigProperty, or a similar AST walker. ' +
374
+ 'Raw-text scanning produces false positives on string literals, template payloads, and docstrings — see TRL-335, ADR-0036.';
375
+
376
+ interface DetectedSite {
377
+ readonly identifier: AstNode;
378
+ readonly message: string;
379
+ readonly severity: 'error' | 'warn';
380
+ readonly start: number;
381
+ }
382
+
383
+ const detectRawScan = (node: AstNode): DetectedSite | null => {
384
+ const scan = rawScanSite(node);
385
+ if (!scan) {
386
+ return null;
387
+ }
388
+ return {
389
+ identifier: scan.identifier,
390
+ message: `${RULE_NAME}: ${scan.identifierName}.${scan.methodName}(...) treats source text as a string. ${DIAGNOSTIC_ADVICE}`,
391
+ severity: 'error',
392
+ start: scan.start,
393
+ };
394
+ };
395
+
396
+ const detectRegexScan = (node: AstNode): DetectedSite | null => {
397
+ const regex = regexScanSite(node);
398
+ if (!regex) {
399
+ return null;
400
+ }
401
+ return {
402
+ identifier: regex.identifier,
403
+ message: `${RULE_NAME}: regex.${regex.methodName}(${regex.identifierName}) scans raw source text. ${DIAGNOSTIC_ADVICE}`,
404
+ severity: 'error',
405
+ start: regex.start,
406
+ };
407
+ };
408
+
409
+ const detectRegexConstruction = (node: AstNode): DetectedSite | null => {
410
+ const construction = regexConstructionSite(node);
411
+ if (!construction) {
412
+ return null;
413
+ }
414
+ const prefix = construction.kind === 'new' ? 'new RegExp' : 'RegExp';
415
+ return {
416
+ identifier: construction.identifier,
417
+ message: `${RULE_NAME}: ${prefix}(${construction.identifierName}) constructs a regex from raw source text. ${DIAGNOSTIC_ADVICE}`,
418
+ severity: 'error',
419
+ start: construction.start,
420
+ };
421
+ };
422
+
423
+ const AST_FIELD_ACCESSORS: ReadonlyMap<string, string> = new Map([
424
+ ['alternate', 'getNodeAlternate'],
425
+ ['argument', 'getNodeArgument'],
426
+ ['arguments', 'getNodeArguments'],
427
+ ['body', 'getNodeBodyNode or getNodeBodyStatements'],
428
+ ['callee', 'getNodeCallee'],
429
+ ['cases', 'getNodeCases'],
430
+ ['computed', 'getNodeComputed'],
431
+ ['consequent', 'getNodeConsequent'],
432
+ ['declaration', 'getNodeDeclaration'],
433
+ ['declarations', 'getNodeDeclarations'],
434
+ ['discriminant', 'getNodeDiscriminant'],
435
+ ['elements', 'getNodeElements'],
436
+ ['exportKind', 'getNodeExportKind'],
437
+ ['exported', 'getNodeExported'],
438
+ ['expression', 'getNodeExpression'],
439
+ ['id', 'getNodeId'],
440
+ ['imported', 'getNodeImported'],
441
+ ['init', 'getNodeInit'],
442
+ ['key', 'getNodeKey'],
443
+ ['kind', 'getNodeKind'],
444
+ ['left', 'getNodeLeft'],
445
+ ['local', 'getNodeLocal'],
446
+ ['name', 'getNodeName'],
447
+ ['object', 'getNodeObject'],
448
+ ['operator', 'getNodeOperator'],
449
+ ['param', 'getNodeParam'],
450
+ ['params', 'getNodeParams'],
451
+ ['properties', 'getNodeProperties'],
452
+ ['property', 'getNodeProperty'],
453
+ ['returnType', 'getNodeReturnType'],
454
+ ['right', 'getNodeRight'],
455
+ ['source', 'getNodeSource'],
456
+ ['specifiers', 'getNodeSpecifiers'],
457
+ ['superClass', 'getNodeSuperClass'],
458
+ ['test', 'getNodeTest'],
459
+ ['typeAnnotation', 'getNodeTypeAnnotation'],
460
+ ['value', 'getNodeValue or getNodeValueNode'],
461
+ ]);
462
+
463
+ const typeLiteralMembers = (
464
+ annotation: AstNode | undefined
465
+ ): readonly AstNode[] => {
466
+ if (annotation?.type !== 'TSTypeLiteral') {
467
+ return [];
468
+ }
469
+ const { members } = annotation;
470
+ return Array.isArray(members) ? members.filter(isAstNode) : [];
471
+ };
472
+
473
+ const typePropertyName = (member: AstNode): string | null => {
474
+ if (member.type !== 'TSPropertySignature' || getNodeComputed(member)) {
475
+ return null;
476
+ }
477
+ const key = getNodeKey(member);
478
+ if (key?.type === 'Identifier') {
479
+ return getNodeName(key) ?? null;
480
+ }
481
+ return key ? getStringValue(key) : null;
482
+ };
483
+
484
+ const knownAccessorEntries = (
485
+ annotation: AstNode | undefined
486
+ ): readonly [string, string][] =>
487
+ typeLiteralMembers(annotation).flatMap((member) => {
488
+ const field = typePropertyName(member);
489
+ const accessor = field ? AST_FIELD_ACCESSORS.get(field) : undefined;
490
+ return field && accessor ? [[field, accessor] as const] : [];
491
+ });
492
+
493
+ const isUnknownAssertion = (node: AstNode | undefined): node is AstNode =>
494
+ node?.type === 'TSAsExpression' &&
495
+ getNodeTypeAnnotation(node)?.type === 'TSUnknownKeyword';
496
+
497
+ const detectRawNodeFieldCast = (node: AstNode): DetectedSite | null => {
498
+ if (node.type !== 'TSAsExpression') {
499
+ return null;
500
+ }
501
+ const expression = getNodeExpression(node);
502
+ if (!isUnknownAssertion(expression)) {
503
+ return null;
504
+ }
505
+ const entries = knownAccessorEntries(getNodeTypeAnnotation(node));
506
+ if (entries.length === 0) {
507
+ return null;
508
+ }
509
+ const fields = entries.map(([field, accessor]) => `${field} -> ${accessor}`);
510
+ return {
511
+ identifier: expression,
512
+ message: `${RULE_NAME}: raw AST node-field cast should use typed helpers from the shared source helpers (${fields.join(', ')}). Raw node-field casts drift from the shared source helper surface.`,
513
+ severity: 'warn',
514
+ start: node.start,
515
+ };
516
+ };
517
+
518
+ /**
519
+ * Dispatch chain for per-node detectors. Each detector tries one family in
520
+ * priority order. First match wins; descent into children still happens so
521
+ * nested offenses (e.g. a regex scan inside a callback passed to a raw-text
522
+ * scan) are still caught.
523
+ */
524
+ const DETECTORS: readonly ((node: AstNode) => DetectedSite | null)[] = [
525
+ detectRawScan,
526
+ detectRegexScan,
527
+ detectRegexConstruction,
528
+ detectRawNodeFieldCast,
529
+ ];
530
+
531
+ const detectSite = (node: AstNode): DetectedSite | null => {
532
+ for (const detector of DETECTORS) {
533
+ const site = detector(node);
534
+ if (site) {
535
+ return site;
536
+ }
537
+ }
538
+ return null;
539
+ };
540
+
541
+ // ---------------------------------------------------------------------------
542
+ // Scope analysis (Option A — parameter-origin tracking).
543
+ //
544
+ // The pre-TRL-346 detectors gated on identifier spelling alone (a fixed set
545
+ // like `sourceCode`, `text`, `source`, `rawText`). That over-fires on
546
+ // unrelated locals that happen to share one of those names, and under-fires
547
+ // when a rule author picks a different name for the source parameter.
548
+ //
549
+ // Option A walks the AST with a scope stack, records the first parameter of
550
+ // any `check` / `checkWithContext` method (its *actual binding name*), and
551
+ // only flags a call site when the candidate identifier still refers to that
552
+ // exact binding — i.e. no intervening `const`/`let`/`var`/param has
553
+ // shadowed it.
554
+ // ---------------------------------------------------------------------------
555
+
556
+ interface Scope {
557
+ readonly declaredNames: ReadonlySet<string>;
558
+ readonly sourceParamName: string | null;
559
+ }
560
+
561
+ /**
562
+ * Walk inner→outer. The first scope that declares `name` is the binding; the
563
+ * identifier resolves to a tracked source-param only when that declaring
564
+ * scope's `sourceParamName` matches. An identifier with no declaring scope
565
+ * (e.g. a free variable) is not a source-param binding.
566
+ */
567
+ const resolvesToSourceParam = (
568
+ name: string,
569
+ scopes: readonly Scope[]
570
+ ): boolean => {
571
+ for (let i = scopes.length - 1; i >= 0; i -= 1) {
572
+ const scope = scopes[i];
573
+ if (scope && scope.declaredNames.has(name)) {
574
+ return scope.sourceParamName === name;
575
+ }
576
+ }
577
+ return false;
578
+ };
579
+
580
+ const FUNCTION_NODE_TYPES: ReadonlySet<string> = new Set([
581
+ 'ArrowFunctionExpression',
582
+ 'FunctionDeclaration',
583
+ 'FunctionExpression',
584
+ ]);
585
+
586
+ /**
587
+ * Name of a function-like node when it is a recognized WardenRule method
588
+ * (`check` or `checkWithContext`). Returns null otherwise.
589
+ *
590
+ * Handles three shapes:
591
+ * - object-literal method shorthand: `{ check(sc) { ... } }`
592
+ * (Property with `method: true`, or MethodDefinition)
593
+ * - arrow/function property: `{ check: (sc) => { ... } }`
594
+ * - top-level function declaration: `function check(sc) { ... }`
595
+ *
596
+ * The context-to-function link is resolved by the caller via the
597
+ * `methodFunctionStarts` map: we pre-walk the AST once to map the start
598
+ * offset of every recognized function to its method name, then consult the
599
+ * map when the scope walker enters that function.
600
+ */
601
+ const methodNameFromKey = (key: AstNode | undefined): string | null => {
602
+ if (!key) {
603
+ return null;
604
+ }
605
+ if (key.type === 'Identifier') {
606
+ return getNodeName(key) ?? null;
607
+ }
608
+ // String-literal keys like `'check': (sc) => { ... }`.
609
+ if (
610
+ (key.type === 'Literal' || key.type === 'StringLiteral') &&
611
+ getStringValue(key) !== null
612
+ ) {
613
+ return getStringValue(key);
614
+ }
615
+ return null;
616
+ };
617
+
618
+ const firstParamIdentifierName = (fn: AstNode): string | null => {
619
+ const params = getNodeParams(fn);
620
+ const [first] = params ?? [];
621
+ if (!first) {
622
+ return null;
623
+ }
624
+ if (first.type === 'Identifier') {
625
+ return getIdentifierName(first);
626
+ }
627
+ if (first.type === 'AssignmentPattern') {
628
+ const left = getNodeLeft(first);
629
+ return left?.type === 'Identifier' ? getIdentifierName(left) : null;
630
+ }
631
+ return null;
632
+ };
633
+
634
+ /**
635
+ * Collect start offsets of function-like AST nodes that represent the body
636
+ * of a recognized WardenRule source-receiving method. Value is the declared
637
+ * first-parameter name, used as `sourceParamName` when the scope walker
638
+ * pushes that function's scope.
639
+ */
640
+ const methodPropertyFunction = (
641
+ node: AstNode
642
+ ): { fn: AstNode; name: string } | null => {
643
+ const key = getNodeKey(node);
644
+ const value = getNodeValueNode(node);
645
+ const name = methodNameFromKey(key);
646
+ if (!name || !value || !FUNCTION_NODE_TYPES.has(value.type)) {
647
+ return null;
648
+ }
649
+ return SOURCE_PARAM_METHOD_NAMES.has(name) ? { fn: value, name } : null;
650
+ };
651
+
652
+ const namedFunctionDeclaration = (
653
+ node: AstNode
654
+ ): { fn: AstNode; name: string } | null => {
655
+ const name = getIdentifierName(getNodeId(node));
656
+ if (!name || !SOURCE_PARAM_METHOD_NAMES.has(name)) {
657
+ return null;
658
+ }
659
+ return { fn: node, name };
660
+ };
661
+
662
+ const recognizedMethodFunction = (
663
+ node: AstNode
664
+ ): { fn: AstNode; name: string } | null => {
665
+ if (node.type === 'Property' || node.type === 'MethodDefinition') {
666
+ return methodPropertyFunction(node);
667
+ }
668
+ if (node.type === 'FunctionDeclaration') {
669
+ return namedFunctionDeclaration(node);
670
+ }
671
+ return null;
672
+ };
673
+
674
+ const buildSourceParamIndex = (ast: AstNode): ReadonlyMap<number, string> => {
675
+ const index = new Map<number, string>();
676
+ walk(ast, (node) => {
677
+ const recognized = recognizedMethodFunction(node);
678
+ if (!recognized) {
679
+ return;
680
+ }
681
+ const paramName = firstParamIdentifierName(recognized.fn);
682
+ if (paramName) {
683
+ index.set(recognized.fn.start, paramName);
684
+ }
685
+ });
686
+ return index;
687
+ };
688
+
689
+ /**
690
+ * Collect identifier names introduced at this scope by
691
+ * `const`/`let`/`var`/function declarations or function params. We only
692
+ * inspect direct children — nested block statements and nested functions
693
+ * have their own scopes.
694
+ */
695
+ const expandObjectPatternProperty = (property: AstNode): readonly AstNode[] => {
696
+ if (property.type === 'Property') {
697
+ const value = getNodeValueNode(property);
698
+ return value ? [value] : [];
699
+ }
700
+ if (property.type === 'RestElement') {
701
+ const argument = getNodeArgument(property);
702
+ return argument ? [argument] : [];
703
+ }
704
+ return [];
705
+ };
706
+
707
+ const PATTERN_EXPANDERS: Record<string, (p: AstNode) => readonly AstNode[]> = {
708
+ ArrayPattern: (pattern) => {
709
+ const elements = getNodeElements(pattern);
710
+ return elements.filter((el): el is AstNode => el !== null);
711
+ },
712
+ AssignmentPattern: (pattern) => {
713
+ const left = getNodeLeft(pattern);
714
+ return left ? [left] : [];
715
+ },
716
+ ObjectPattern: (pattern) => {
717
+ const properties = getNodeProperties(pattern) ?? [];
718
+ return properties.flatMap(expandObjectPatternProperty);
719
+ },
720
+ RestElement: (pattern) => {
721
+ const argument = getNodeArgument(pattern);
722
+ return argument ? [argument] : [];
723
+ },
724
+ };
725
+
726
+ /**
727
+ * Collect identifier names introduced by a binding pattern (function
728
+ * parameter, destructuring target, etc.). Iterative worklist over
729
+ * {@link PATTERN_EXPANDERS}: each expander yields one level of child
730
+ * patterns, and the loop bottoms out at `Identifier` nodes. The iterative
731
+ * shape avoids mutual recursion so every helper stays under the
732
+ * `max-statements` budget.
733
+ */
734
+ const visitPatternNode = (
735
+ current: AstNode,
736
+ into: Set<string>,
737
+ worklist: AstNode[]
738
+ ): void => {
739
+ if (current.type === 'Identifier') {
740
+ const name = getIdentifierName(current);
741
+ if (name) {
742
+ into.add(name);
743
+ }
744
+ return;
745
+ }
746
+ const expand = PATTERN_EXPANDERS[current.type];
747
+ if (expand) {
748
+ worklist.push(...expand(current));
749
+ }
750
+ };
751
+
752
+ const collectBindingIdsFromPattern = (
753
+ pattern: AstNode | undefined,
754
+ into: Set<string>
755
+ ): void => {
756
+ if (!pattern) {
757
+ return;
758
+ }
759
+ const worklist: AstNode[] = [pattern];
760
+ while (worklist.length > 0) {
761
+ const current = worklist.pop();
762
+ if (current) {
763
+ visitPatternNode(current, into, worklist);
764
+ }
765
+ }
766
+ };
767
+
768
+ interface FunctionScopeBindingsEx {
769
+ /** Param names exactly — used to identify the source-param binding. */
770
+ readonly paramNames: Set<string>;
771
+ /** Hoisted `var` names inside the function body. May overlap with params. */
772
+ readonly hoistedVarNames: Set<string>;
773
+ /** Combined set of declared names visible in this function scope. */
774
+ readonly declaredNames: Set<string>;
775
+ }
776
+
777
+ const collectParamBindingsFromFunction = (fn: AstNode): Set<string> => {
778
+ const paramNames = new Set<string>();
779
+ const params = getNodeParams(fn) ?? [];
780
+ for (const param of params) {
781
+ collectBindingIdsFromPattern(param, paramNames);
782
+ }
783
+ return paramNames;
784
+ };
785
+
786
+ const collectHoistedVarsFromFunctionBody = (fn: AstNode): Set<string> => {
787
+ const hoistedVarNames = new Set<string>();
788
+ const body = getNodeBodyNode(fn);
789
+ if (body && typeof body === 'object' && (body as AstNode).type) {
790
+ // eslint-disable-next-line no-use-before-define
791
+ collectHoistedVarBindings(body, hoistedVarNames);
792
+ }
793
+ return hoistedVarNames;
794
+ };
795
+
796
+ /**
797
+ * Combine param names and body-level hoisted `var` names into a single
798
+ * declared-names set, while keeping the two subsets addressable. The
799
+ * hoisted set is kept separate so the scope walker can tell whether a
800
+ * source-param identity has been overwritten by a same-named hoisted local —
801
+ * a shadow that would otherwise be invisible because both names live in the
802
+ * same function-scope binding set.
803
+ */
804
+ const collectFunctionScopeBindingsEx = (
805
+ fn: AstNode
806
+ ): FunctionScopeBindingsEx => {
807
+ const paramNames = collectParamBindingsFromFunction(fn);
808
+ const hoistedVarNames = collectHoistedVarsFromFunctionBody(fn);
809
+ const declaredNames = new Set<string>(paramNames);
810
+ for (const name of hoistedVarNames) {
811
+ declaredNames.add(name);
812
+ }
813
+ return { declaredNames, hoistedVarNames, paramNames };
814
+ };
815
+
816
+ const addVariableDeclarationNames = (
817
+ stmt: AstNode,
818
+ names: Set<string>
819
+ ): void => {
820
+ const declarations = getNodeDeclarations(stmt) ?? [];
821
+ for (const decl of declarations) {
822
+ collectBindingIdsFromPattern(getNodeId(decl), names);
823
+ }
824
+ };
825
+
826
+ const isVarVariableDeclaration = (stmt: AstNode): boolean =>
827
+ stmt.type === 'VariableDeclaration' && getNodeKind(stmt) === 'var';
828
+
829
+ /**
830
+ * True when `node` owns its own VariableEnvironment and therefore stops `var`
831
+ * hoisting from composing into the enclosing function/program scope.
832
+ */
833
+ const ownsVariableEnvironmentForHoisting = (node: AstNode): boolean =>
834
+ FUNCTION_NODE_TYPES.has(node.type) || node.type === 'StaticBlock';
835
+
836
+ /**
837
+ * Collect `var` declarations that hoist to the nearest function (or program)
838
+ * scope from anywhere inside `root`, without composing a nested function or
839
+ * static-block boundary. Mirrors the hoisting semantics used by
840
+ * {@link ./no-sync-result-assumption.ts} so `if (cond) { var sourceCode = ... }`
841
+ * inside a `check()` body correctly shadows the method's first parameter.
842
+ */
843
+ const collectHoistedVarBindings = (root: AstNode, out: Set<string>): void => {
844
+ const walkVar = (node: AstNode, isRoot: boolean): void => {
845
+ if (!isRoot && ownsVariableEnvironmentForHoisting(node)) {
846
+ return;
847
+ }
848
+ if (isVarVariableDeclaration(node)) {
849
+ addVariableDeclarationNames(node, out);
850
+ }
851
+ for (const val of Object.values(node)) {
852
+ if (Array.isArray(val)) {
853
+ for (const item of val) {
854
+ if (item && typeof item === 'object' && (item as AstNode).type) {
855
+ walkVar(item as AstNode, false);
856
+ }
857
+ }
858
+ } else if (val && typeof val === 'object' && (val as AstNode).type) {
859
+ walkVar(val as AstNode, false);
860
+ }
861
+ }
862
+ };
863
+ walkVar(root, true);
864
+ };
865
+
866
+ const addFunctionDeclarationName = (
867
+ stmt: AstNode,
868
+ names: Set<string>
869
+ ): void => {
870
+ const name = getIdentifierName(getNodeId(stmt));
871
+ if (name) {
872
+ names.add(name);
873
+ }
874
+ };
875
+
876
+ const collectBlockDeclarationNames = (block: AstNode): Set<string> => {
877
+ const names = new Set<string>();
878
+ const body = getNodeBodyStatements(block);
879
+ for (const stmt of body) {
880
+ // `var` is function-scoped, not block-scoped — hoisted into the nearest
881
+ // enclosing function (or program) scope by
882
+ // {@link collectHoistedVarBindings}. Registering it here would make
883
+ // `if (cond) { var x = ... }` look block-local and fail to shadow a
884
+ // same-named outer binding when the write escapes the block.
885
+ if (
886
+ stmt.type === 'VariableDeclaration' &&
887
+ !isVarVariableDeclaration(stmt)
888
+ ) {
889
+ addVariableDeclarationNames(stmt, names);
890
+ } else if (stmt.type === 'FunctionDeclaration') {
891
+ addFunctionDeclarationName(stmt, names);
892
+ }
893
+ }
894
+ return names;
895
+ };
896
+
897
+ /**
898
+ * Collect the names a `CatchClause` parameter introduces into its body. The
899
+ * catch clause has its own binding scope distinct from the surrounding block;
900
+ * without this, `try {} catch (sourceCode) { sourceCode.split(...) }` would
901
+ * resolve `sourceCode` to the enclosing `check()` parameter and fire.
902
+ */
903
+ const collectCatchClauseDeclarationNames = (node: AstNode): Set<string> => {
904
+ const names = new Set<string>();
905
+ const param = getNodeParam(node);
906
+ collectBindingIdsFromPattern(param, names);
907
+ return names;
908
+ };
909
+
910
+ interface ScopeWalkContext {
911
+ readonly diagnostics: WardenDiagnostic[];
912
+ readonly filePath: string;
913
+ readonly methodFunctionStarts: ReadonlyMap<number, string>;
914
+ readonly sourceCode: string;
915
+ }
916
+
917
+ const recordDiagnostic = (ctx: ScopeWalkContext, site: DetectedSite): void => {
918
+ ctx.diagnostics.push({
919
+ filePath: ctx.filePath,
920
+ line: offsetToLine(ctx.sourceCode, site.start),
921
+ message: site.message,
922
+ rule: RULE_NAME,
923
+ severity: site.severity,
924
+ });
925
+ };
926
+
927
+ /**
928
+ * Emit a diagnostic if `node` is a detected site whose candidate identifier
929
+ * resolves (via the current scope stack) to a tracked source-param binding.
930
+ */
931
+ const maybeRecordDetection = (
932
+ node: AstNode,
933
+ scopes: readonly Scope[],
934
+ ctx: ScopeWalkContext
935
+ ): void => {
936
+ const site = detectSite(node);
937
+ if (!site) {
938
+ return;
939
+ }
940
+ if (site.severity === 'warn') {
941
+ recordDiagnostic(ctx, site);
942
+ return;
943
+ }
944
+ const name = getIdentifierName(site.identifier);
945
+ if (name && resolvesToSourceParam(name, scopes)) {
946
+ recordDiagnostic(ctx, site);
947
+ }
948
+ };
949
+
950
+ /**
951
+ * Resolve the effective source-param name for a function scope. A hoisted
952
+ * `var` with the same name as the source param overwrites the param's slot
953
+ * in the function's VariableEnvironment, so the enclosing identifier no
954
+ * longer resolves to the raw-source binding.
955
+ */
956
+ const resolveScopeSourceParamName = (
957
+ methodParamName: string | null,
958
+ hoistedVarNames: ReadonlySet<string>
959
+ ): string | null =>
960
+ methodParamName && hoistedVarNames.has(methodParamName)
961
+ ? null
962
+ : methodParamName;
963
+
964
+ const pushFunctionScope = (
965
+ node: AstNode,
966
+ ctx: ScopeWalkContext,
967
+ scopes: Scope[]
968
+ ): void => {
969
+ const methodParamName = ctx.methodFunctionStarts.get(node.start) ?? null;
970
+ const { declaredNames, hoistedVarNames } =
971
+ collectFunctionScopeBindingsEx(node);
972
+ scopes.push({
973
+ declaredNames,
974
+ sourceParamName: resolveScopeSourceParamName(
975
+ methodParamName,
976
+ hoistedVarNames
977
+ ),
978
+ });
979
+ };
980
+
981
+ /** Collector for scope frames that carry no source-param identity. */
982
+ const SIMPLE_SCOPE_COLLECTORS: Record<
983
+ string,
984
+ (node: AstNode) => ReadonlySet<string>
985
+ > = {
986
+ BlockStatement: collectBlockDeclarationNames,
987
+ CatchClause: collectCatchClauseDeclarationNames,
988
+ };
989
+
990
+ /**
991
+ * Push the scope a function node introduces, or null when the node is not
992
+ * scope-introducing. Returning a dispose function keeps `visitWithScopes`
993
+ * small and keeps the scope stack strictly paired.
994
+ */
995
+ const enterScopeForNode = (
996
+ node: AstNode,
997
+ ctx: ScopeWalkContext,
998
+ scopes: Scope[]
999
+ ): boolean => {
1000
+ if (FUNCTION_NODE_TYPES.has(node.type)) {
1001
+ pushFunctionScope(node, ctx, scopes);
1002
+ return true;
1003
+ }
1004
+ const collector = SIMPLE_SCOPE_COLLECTORS[node.type];
1005
+ if (collector) {
1006
+ scopes.push({
1007
+ declaredNames: collector(node),
1008
+ sourceParamName: null,
1009
+ });
1010
+ return true;
1011
+ }
1012
+ return false;
1013
+ };
1014
+
1015
+ interface EnterFrame {
1016
+ kind: 'enter';
1017
+ node: AstNode;
1018
+ }
1019
+ type WalkFrame = EnterFrame | { kind: 'leave'; pushed: boolean };
1020
+
1021
+ /**
1022
+ * Build "enter" frames for every AST child of `node`. Returned reversed so
1023
+ * consumers can `Array#push(...frames)` onto a stack and still visit
1024
+ * children in source order via `Array#pop`.
1025
+ */
1026
+ const collectChildFrames = (node: AstNode): readonly EnterFrame[] => {
1027
+ const frames: EnterFrame[] = [];
1028
+ for (const value of Object.values(node)) {
1029
+ if (Array.isArray(value)) {
1030
+ for (const item of value) {
1031
+ if (item && typeof item === 'object' && (item as AstNode).type) {
1032
+ frames.push({ kind: 'enter', node: item as AstNode });
1033
+ }
1034
+ }
1035
+ continue;
1036
+ }
1037
+ if (value && typeof value === 'object' && (value as AstNode).type) {
1038
+ frames.push({ kind: 'enter', node: value as AstNode });
1039
+ }
1040
+ }
1041
+ return frames.toReversed();
1042
+ };
1043
+
1044
+ const processFrame = (
1045
+ frame: WalkFrame,
1046
+ scopes: Scope[],
1047
+ ctx: ScopeWalkContext,
1048
+ stack: WalkFrame[]
1049
+ ): void => {
1050
+ if (frame.kind === 'leave') {
1051
+ if (frame.pushed) {
1052
+ scopes.pop();
1053
+ }
1054
+ return;
1055
+ }
1056
+ const { node } = frame;
1057
+ maybeRecordDetection(node, scopes, ctx);
1058
+ const pushed = enterScopeForNode(node, ctx, scopes);
1059
+ stack.push({ kind: 'leave', pushed });
1060
+ stack.push(...collectChildFrames(node));
1061
+ };
1062
+
1063
+ /**
1064
+ * Scope-aware AST walker. Iterative DFS: each enter frame schedules the
1065
+ * node's children in source order and queues a matching leave frame so
1066
+ * scope pops stay balanced with their pushes.
1067
+ */
1068
+ const analyze = (
1069
+ sourceCode: string,
1070
+ filePath: string,
1071
+ ast: AstNode
1072
+ ): readonly WardenDiagnostic[] => {
1073
+ const ctx: ScopeWalkContext = {
1074
+ diagnostics: [],
1075
+ filePath,
1076
+ methodFunctionStarts: buildSourceParamIndex(ast),
1077
+ sourceCode,
1078
+ };
1079
+ const scopes: Scope[] = [];
1080
+ const stack: WalkFrame[] = [{ kind: 'enter', node: ast }];
1081
+ while (stack.length > 0) {
1082
+ const frame = stack.pop();
1083
+ if (frame) {
1084
+ processFrame(frame, scopes, ctx, stack);
1085
+ }
1086
+ }
1087
+ return ctx.diagnostics;
1088
+ };
1089
+
1090
+ /**
1091
+ * Warden rule enforcing that warden rules themselves walk the AST rather than
1092
+ * regex-scan raw source text or hand-cast recurring AST node fields.
1093
+ */
1094
+ export const wardenRulesUseAst: WardenRule = {
1095
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
1096
+ if (!isTargetFile(filePath)) {
1097
+ return [];
1098
+ }
1099
+ const ast = parse(filePath, sourceCode);
1100
+ if (!ast) {
1101
+ return [];
1102
+ }
1103
+ return analyze(sourceCode, filePath, ast);
1104
+ },
1105
+ description:
1106
+ 'Enforces that warden rules inspect source code via @ontrails/source helpers rather than regex-scanning raw source text or hand-casting node fields.',
1107
+ name: RULE_NAME,
1108
+ severity: 'error',
1109
+ };