@ontrails/warden 0.2.0

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 (188) hide show
  1. package/CHANGELOG.md +1556 -0
  2. package/README.md +200 -0
  3. package/bin/warden.ts +51 -0
  4. package/package.json +51 -0
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +2159 -0
  7. package/src/command.ts +1167 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +260 -0
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +178 -0
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +295 -0
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/regrade-history.ts +188 -0
  18. package/src/regrade-receipt.ts +484 -0
  19. package/src/resolve.ts +531 -0
  20. package/src/rules/activation-orphan.ts +97 -0
  21. package/src/rules/captured-kernel.ts +375 -0
  22. package/src/rules/circular-refs.ts +150 -0
  23. package/src/rules/cli-command-route-coherence.ts +177 -0
  24. package/src/rules/composes-declarations.ts +839 -0
  25. package/src/rules/context-no-surface-types.ts +214 -0
  26. package/src/rules/dead-internal-trail.ts +161 -0
  27. package/src/rules/dead-public-trail.ts +258 -0
  28. package/src/rules/draft-file-marking.ts +155 -0
  29. package/src/rules/draft-visible-debt.ts +83 -0
  30. package/src/rules/duplicate-exported-symbol.ts +172 -0
  31. package/src/rules/duplicate-public-contract.ts +137 -0
  32. package/src/rules/entity-exists.ts +254 -0
  33. package/src/rules/entity-ids.ts +15 -0
  34. package/src/rules/error-mapping-completeness.ts +290 -0
  35. package/src/rules/example-valid.ts +395 -0
  36. package/src/rules/fires-declarations.ts +740 -0
  37. package/src/rules/governed-symbol-residue.ts +535 -0
  38. package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
  39. package/src/rules/implementation-returns-result.ts +1733 -0
  40. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  41. package/src/rules/incomplete-crud.ts +583 -0
  42. package/src/rules/index.ts +328 -0
  43. package/src/rules/intent-propagation.ts +125 -0
  44. package/src/rules/layer-field-name-drift.ts +102 -0
  45. package/src/rules/library-render-coherence.ts +97 -0
  46. package/src/rules/metadata.ts +929 -0
  47. package/src/rules/missing-reconcile.ts +97 -0
  48. package/src/rules/missing-visibility.ts +111 -0
  49. package/src/rules/no-destructured-compose.ts +196 -0
  50. package/src/rules/no-dev-permit-in-source.ts +99 -0
  51. package/src/rules/no-direct-implementation-call.ts +52 -0
  52. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  53. package/src/rules/no-legacy-layer-imports.ts +211 -0
  54. package/src/rules/no-native-error-result.ts +118 -0
  55. package/src/rules/no-redundant-result-error-wrap.ts +382 -0
  56. package/src/rules/no-retired-cross-vocabulary.ts +203 -0
  57. package/src/rules/no-sync-result-assumption.ts +1199 -0
  58. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  59. package/src/rules/no-throw-in-implementation.ts +48 -0
  60. package/src/rules/no-top-level-surface.ts +371 -0
  61. package/src/rules/on-references-exist.ts +194 -0
  62. package/src/rules/orphaned-signal.ts +149 -0
  63. package/src/rules/owner-render-parity.ts +143 -0
  64. package/src/rules/permit-governance.ts +25 -0
  65. package/src/rules/prefer-schema-inference.ts +141 -0
  66. package/src/rules/public-export-example-coverage.ts +561 -0
  67. package/src/rules/public-internal-deep-imports.ts +454 -0
  68. package/src/rules/public-output-schema.ts +29 -0
  69. package/src/rules/public-union-output-discriminants.ts +150 -0
  70. package/src/rules/read-intent-fires.ts +188 -0
  71. package/src/rules/reference-exists.ts +97 -0
  72. package/src/rules/registry-names.ts +171 -0
  73. package/src/rules/resolved-import-boundary.ts +146 -0
  74. package/src/rules/resource-declarations.ts +697 -0
  75. package/src/rules/resource-exists.ts +181 -0
  76. package/src/rules/resource-id-grammar.ts +65 -0
  77. package/src/rules/resource-mock-coverage.ts +115 -0
  78. package/src/rules/retired-vocabulary.ts +1407 -0
  79. package/src/rules/scan.ts +59 -0
  80. package/src/rules/scheduled-destroy-intent.ts +44 -0
  81. package/src/rules/signal-graph-coaching.ts +220 -0
  82. package/src/rules/source/composition.ts +165 -0
  83. package/src/rules/source/drafts.ts +164 -0
  84. package/src/rules/source/entities.ts +618 -0
  85. package/src/rules/source/pragmas.ts +45 -0
  86. package/src/rules/source/resources.ts +64 -0
  87. package/src/rules/source/signals.ts +397 -0
  88. package/src/rules/source/stores.ts +310 -0
  89. package/src/rules/specs.ts +388 -0
  90. package/src/rules/static-resource-accessor-preference.ts +654 -0
  91. package/src/rules/structure.ts +234 -0
  92. package/src/rules/surface-overlay-coherence.ts +262 -0
  93. package/src/rules/surface-trailhead-coherence.ts +366 -0
  94. package/src/rules/trail-fork-coaching.ts +625 -0
  95. package/src/rules/trail-versioning-source.ts +1076 -0
  96. package/src/rules/trail-versioning-topo.ts +172 -0
  97. package/src/rules/trailhead-override-divergence.ts +356 -0
  98. package/src/rules/types.ts +464 -0
  99. package/src/rules/unmaterialized-activation-source.ts +85 -0
  100. package/src/rules/unreachable-detour-shadowing.ts +343 -0
  101. package/src/rules/valid-describe-refs.ts +224 -0
  102. package/src/rules/valid-detour-contract.ts +78 -0
  103. package/src/rules/warden-export-symmetry.ts +540 -0
  104. package/src/rules/warden-rules-use-ast.ts +1109 -0
  105. package/src/rules/webhook-route-collision.ts +306 -0
  106. package/src/rules/workspace-lock-ownership.ts +34 -0
  107. package/src/trails/activation-orphan.trail.ts +84 -0
  108. package/src/trails/captured-kernel.trail.ts +108 -0
  109. package/src/trails/circular-refs.trail.ts +29 -0
  110. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  111. package/src/trails/composes-declarations.trail.ts +22 -0
  112. package/src/trails/context-no-surface-types.trail.ts +21 -0
  113. package/src/trails/dead-internal-trail.trail.ts +26 -0
  114. package/src/trails/dead-public-trail.trail.ts +31 -0
  115. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  116. package/src/trails/draft-file-marking.trail.ts +16 -0
  117. package/src/trails/draft-visible-debt.trail.ts +16 -0
  118. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  119. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  120. package/src/trails/entity-exists.trail.ts +21 -0
  121. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  122. package/src/trails/example-valid.trail.ts +25 -0
  123. package/src/trails/fires-declarations.trail.ts +23 -0
  124. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  125. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  126. package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
  127. package/src/trails/implementation-returns-result.trail.ts +20 -0
  128. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  129. package/src/trails/incomplete-crud.trail.ts +39 -0
  130. package/src/trails/index.ts +91 -0
  131. package/src/trails/intent-propagation.trail.ts +30 -0
  132. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  133. package/src/trails/library-render-coherence.trail.ts +43 -0
  134. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  135. package/src/trails/missing-reconcile.trail.ts +33 -0
  136. package/src/trails/missing-visibility.trail.ts +22 -0
  137. package/src/trails/no-destructured-compose.trail.ts +44 -0
  138. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  139. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  140. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  141. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  142. package/src/trails/no-native-error-result.trail.ts +18 -0
  143. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  144. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  145. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  146. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  147. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  148. package/src/trails/no-top-level-surface.trail.ts +43 -0
  149. package/src/trails/on-references-exist.trail.ts +21 -0
  150. package/src/trails/orphaned-signal.trail.ts +36 -0
  151. package/src/trails/owner-render-parity.trail.ts +26 -0
  152. package/src/trails/pending-force.trail.ts +21 -0
  153. package/src/trails/permit-governance.trail.ts +51 -0
  154. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  155. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  156. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  157. package/src/trails/public-output-schema.trail.ts +55 -0
  158. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  159. package/src/trails/read-intent-fires.trail.ts +20 -0
  160. package/src/trails/reference-exists.trail.ts +25 -0
  161. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  162. package/src/trails/resource-declarations.trail.ts +25 -0
  163. package/src/trails/resource-exists.trail.ts +27 -0
  164. package/src/trails/resource-id-grammar.trail.ts +39 -0
  165. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  166. package/src/trails/run.ts +205 -0
  167. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  168. package/src/trails/schema.ts +295 -0
  169. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  170. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  171. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  172. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  173. package/src/trails/topo.ts +6 -0
  174. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  175. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  176. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  177. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  178. package/src/trails/valid-describe-refs.trail.ts +18 -0
  179. package/src/trails/valid-detour-contract.trail.ts +71 -0
  180. package/src/trails/version-gap.trail.ts +35 -0
  181. package/src/trails/version-pinned-compose.trail.ts +23 -0
  182. package/src/trails/version-without-examples.trail.ts +38 -0
  183. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  184. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  185. package/src/trails/webhook-route-collision.trail.ts +50 -0
  186. package/src/trails/workspace-lock-ownership.trail.ts +16 -0
  187. package/src/trails/wrap-rule.ts +269 -0
  188. package/src/workspaces.ts +264 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Validates that every signal id declared in a trail's `on:` array resolves
3
+ * to a known signal definition somewhere in the project.
4
+ *
5
+ * Mirrors `resource-exists` structurally — collects local signal definitions
6
+ * for the standalone `check()` path and accepts a project-wide
7
+ * `knownSignalIds` set via `checkWithContext()`.
8
+ */
9
+
10
+ import { isDraftId } from '@ontrails/core';
11
+
12
+ import { collectSignalDefinitionIds } from './source/signals.js';
13
+ import {
14
+ deriveConstString,
15
+ findConfigProperty,
16
+ findTrailDefinitions,
17
+ getStringValue,
18
+ identifierName,
19
+ isStringLiteral,
20
+ offsetToLine,
21
+ parse,
22
+ } from '@ontrails/source';
23
+ import type { AstNode } from '@ontrails/source';
24
+ import { isTestFile } from './scan.js';
25
+ import type {
26
+ ProjectAwareWardenRule,
27
+ ProjectContext,
28
+ WardenDiagnostic,
29
+ } from './types.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Declared `on:` extraction
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const getOnElements = (config: AstNode): readonly AstNode[] => {
36
+ const onProp = findConfigProperty(config, 'on');
37
+ if (!onProp) {
38
+ return [];
39
+ }
40
+
41
+ const arrayNode = onProp.value;
42
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
43
+ return [];
44
+ }
45
+
46
+ const elements = (arrayNode as AstNode)['elements'] as
47
+ | readonly AstNode[]
48
+ | undefined;
49
+ return elements ?? [];
50
+ };
51
+
52
+ /**
53
+ * Resolve an `on:` array element to a signal id when possible.
54
+ *
55
+ * Handles string literals and `const NAME = 'id'` identifier references.
56
+ * Object-form entries (e.g. `on: [someSignal]` where `someSignal` is a
57
+ * `Signal` value) cannot be statically resolved here and are skipped — the
58
+ * runtime normalizes them inside `trail()`, so skipping is safe. The tradeoff
59
+ * is that typo'd Signal imports won't be caught at lint time; the TypeScript
60
+ * compiler catches those instead.
61
+ */
62
+ const extractOnElementId = (
63
+ element: AstNode,
64
+ sourceCode: string
65
+ ): string | null => {
66
+ if (element.type === 'Identifier') {
67
+ const name = identifierName(element);
68
+ return name ? deriveConstString(name, sourceCode) : null;
69
+ }
70
+
71
+ if (isStringLiteral(element)) {
72
+ return getStringValue(element);
73
+ }
74
+
75
+ return null;
76
+ };
77
+
78
+ const extractDeclaredOnIds = (
79
+ config: AstNode,
80
+ sourceCode: string
81
+ ): readonly string[] => [
82
+ ...new Set(
83
+ getOnElements(config).flatMap((element) => {
84
+ const id = extractOnElementId(element, sourceCode);
85
+ return id ? [id] : [];
86
+ })
87
+ ),
88
+ ];
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Diagnostics
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const buildMissingSignalDiagnostic = (
95
+ trailId: string,
96
+ signalId: string,
97
+ filePath: string,
98
+ line: number
99
+ ): WardenDiagnostic => ({
100
+ filePath,
101
+ line,
102
+ message: `Trail "${trailId}" declares on: "${signalId}" which is not a known signal in the project. Define it with signal('${signalId}', ...), include it in the topo, or fix the on: id if this is a typo.`,
103
+ rule: 'on-references-exist',
104
+ severity: 'error',
105
+ });
106
+
107
+ const reportMissingSignals = (
108
+ def: { id: string; config: AstNode; start: number },
109
+ sourceCode: string,
110
+ filePath: string,
111
+ knownSignalIds: ReadonlySet<string>,
112
+ diagnostics: WardenDiagnostic[]
113
+ ): void => {
114
+ const line = offsetToLine(sourceCode, def.start);
115
+ for (const signalId of extractDeclaredOnIds(def.config, sourceCode)) {
116
+ if (!knownSignalIds.has(signalId) && !isDraftId(signalId)) {
117
+ diagnostics.push(
118
+ buildMissingSignalDiagnostic(def.id, signalId, filePath, line)
119
+ );
120
+ }
121
+ }
122
+ };
123
+
124
+ const buildSignalDiagnostics = (
125
+ ast: AstNode,
126
+ sourceCode: string,
127
+ filePath: string,
128
+ knownSignalIds: ReadonlySet<string>
129
+ ): readonly WardenDiagnostic[] => {
130
+ const diagnostics: WardenDiagnostic[] = [];
131
+ for (const def of findTrailDefinitions(ast)) {
132
+ if (def.kind !== 'trail') {
133
+ continue;
134
+ }
135
+ reportMissingSignals(
136
+ def,
137
+ sourceCode,
138
+ filePath,
139
+ knownSignalIds,
140
+ diagnostics
141
+ );
142
+ }
143
+ return diagnostics;
144
+ };
145
+
146
+ const checkOnReferences = (
147
+ ast: AstNode | null,
148
+ sourceCode: string,
149
+ filePath: string,
150
+ knownSignalIds: ReadonlySet<string>
151
+ ): readonly WardenDiagnostic[] => {
152
+ if (isTestFile(filePath) || !ast) {
153
+ return [];
154
+ }
155
+ return buildSignalDiagnostics(ast, sourceCode, filePath, knownSignalIds);
156
+ };
157
+
158
+ /**
159
+ * Checks that every `on:` reference resolves to a known signal definition.
160
+ */
161
+ export const onReferencesExist: ProjectAwareWardenRule = {
162
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
163
+ const ast = parse(filePath, sourceCode);
164
+ if (!ast) {
165
+ return [];
166
+ }
167
+ return checkOnReferences(
168
+ ast,
169
+ sourceCode,
170
+ filePath,
171
+ collectSignalDefinitionIds(ast)
172
+ );
173
+ },
174
+ checkWithContext(
175
+ sourceCode: string,
176
+ filePath: string,
177
+ context: ProjectContext
178
+ ): readonly WardenDiagnostic[] {
179
+ const ast = parse(filePath, sourceCode);
180
+ const localSignalIds = ast
181
+ ? collectSignalDefinitionIds(ast)
182
+ : new Set<string>();
183
+ return checkOnReferences(
184
+ ast,
185
+ sourceCode,
186
+ filePath,
187
+ context.knownSignalIds ?? localSignalIds
188
+ );
189
+ },
190
+ description:
191
+ 'Ensure every signal id declared in a trail on: array resolves to a known signal definition.',
192
+ name: 'on-references-exist',
193
+ severity: 'error',
194
+ };
@@ -0,0 +1,149 @@
1
+ import { collectOnTargetSignalIds } from './source/signals.js';
2
+ import {
3
+ collectCrudTableIds,
4
+ findStoreTableDefinitions,
5
+ } from './source/stores.js';
6
+ import { offsetToLine, parse } from '@ontrails/source';
7
+ import type { AstNode } from '@ontrails/source';
8
+ import { isTestFile } from './scan.js';
9
+ import type {
10
+ ProjectAwareWardenRule,
11
+ ProjectContext,
12
+ WardenDiagnostic,
13
+ } from './types.js';
14
+
15
+ const CHANGE_SIGNAL_OPERATIONS = ['created', 'updated', 'removed'] as const;
16
+
17
+ const buildOrphanedSignalDiagnostic = (
18
+ tableId: string,
19
+ missingSignalIds: readonly string[],
20
+ filePath: string,
21
+ line: number
22
+ ): WardenDiagnostic => ({
23
+ filePath,
24
+ line,
25
+ message: `Store table "${tableId}" derives change signals with no trail on: consumers: ${missingSignalIds.join(', ')}. Consume them with trail on: declarations — prefer the table's typed signal handles (table.signals.created / .updated / .removed), which carry the store-bound identity; a string id must be fully scoped when the store is bound to a resource — or remove the unused reactive pattern if nothing should react to this table's changes.`,
26
+ rule: 'orphaned-signal',
27
+ severity: 'warn',
28
+ });
29
+
30
+ const getMissingSignalIds = (
31
+ tableId: string,
32
+ onTargetSignalIds: ReadonlySet<string>
33
+ ): readonly string[] =>
34
+ CHANGE_SIGNAL_OPERATIONS.map((operation) => `${tableId}.${operation}`).filter(
35
+ (signalId) =>
36
+ !onTargetSignalIds.has(signalId) &&
37
+ // Bare-name fallback: string-literal `on:` consumers store the signal
38
+ // without the composite `${storeBinding}:` prefix.
39
+ !onTargetSignalIds.has(signalId.replace(/^[^:]+:/, ''))
40
+ );
41
+
42
+ /**
43
+ * Strip the `${storeBinding}:` prefix from a composite signal id for display.
44
+ * Keeps diagnostic messages readable while keeping keys composite internally.
45
+ */
46
+ const stripStoreBinding = (
47
+ signalId: string,
48
+ storeBinding: string | null
49
+ ): string => {
50
+ if (!storeBinding) {
51
+ return signalId;
52
+ }
53
+ const prefix = `${storeBinding}:`;
54
+ return signalId.startsWith(prefix) ? signalId.slice(prefix.length) : signalId;
55
+ };
56
+
57
+ const buildDefinitionDiagnostic = (
58
+ definition: ReturnType<typeof findStoreTableDefinitions>[number],
59
+ sourceCode: string,
60
+ filePath: string,
61
+ crudTableIds: ReadonlySet<string>,
62
+ onTargetSignalIds: ReadonlySet<string>
63
+ ): WardenDiagnostic | null => {
64
+ if (!crudTableIds.has(definition.key)) {
65
+ return null;
66
+ }
67
+
68
+ const missingSignalIds = getMissingSignalIds(
69
+ definition.key,
70
+ onTargetSignalIds
71
+ );
72
+ return missingSignalIds.length === 0
73
+ ? null
74
+ : buildOrphanedSignalDiagnostic(
75
+ definition.name,
76
+ missingSignalIds.map((id) =>
77
+ stripStoreBinding(id, definition.storeBinding)
78
+ ),
79
+ filePath,
80
+ offsetToLine(sourceCode, definition.start)
81
+ );
82
+ };
83
+
84
+ const checkOrphanedSignals = (
85
+ ast: AstNode | null,
86
+ sourceCode: string,
87
+ filePath: string,
88
+ crudTableIds: ReadonlySet<string>,
89
+ onTargetSignalIds: ReadonlySet<string>
90
+ ): readonly WardenDiagnostic[] => {
91
+ if (isTestFile(filePath) || !ast) {
92
+ return [];
93
+ }
94
+
95
+ const diagnostics: WardenDiagnostic[] = [];
96
+
97
+ for (const definition of findStoreTableDefinitions(ast)) {
98
+ const diagnostic = buildDefinitionDiagnostic(
99
+ definition,
100
+ sourceCode,
101
+ filePath,
102
+ crudTableIds,
103
+ onTargetSignalIds
104
+ );
105
+ if (diagnostic) {
106
+ diagnostics.push(diagnostic);
107
+ }
108
+ }
109
+
110
+ return diagnostics;
111
+ };
112
+
113
+ export const orphanedSignal: ProjectAwareWardenRule = {
114
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
115
+ const ast = parse(filePath, sourceCode);
116
+ return checkOrphanedSignals(
117
+ ast,
118
+ sourceCode,
119
+ filePath,
120
+ ast ? collectCrudTableIds(ast) : new Set<string>(),
121
+ ast ? collectOnTargetSignalIds(ast, sourceCode) : new Set<string>()
122
+ );
123
+ },
124
+ checkWithContext(
125
+ sourceCode: string,
126
+ filePath: string,
127
+ context: ProjectContext
128
+ ): readonly WardenDiagnostic[] {
129
+ const ast = parse(filePath, sourceCode);
130
+ const localCrudTableIds = ast
131
+ ? collectCrudTableIds(ast)
132
+ : new Set<string>();
133
+ const localOnTargetSignalIds = ast
134
+ ? collectOnTargetSignalIds(ast, sourceCode)
135
+ : new Set<string>();
136
+
137
+ return checkOrphanedSignals(
138
+ ast,
139
+ sourceCode,
140
+ filePath,
141
+ context.crudTableIds ?? localCrudTableIds,
142
+ context.onTargetSignalIds ?? localOnTargetSignalIds
143
+ );
144
+ },
145
+ description:
146
+ 'Warn when CRUD-backed store change signals are never consumed by trail on: declarations.',
147
+ name: 'orphaned-signal',
148
+ severity: 'warn',
149
+ };
@@ -0,0 +1,143 @@
1
+ import { resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import { intentValues } from '@ontrails/core';
5
+
6
+ import {
7
+ getNodeExpression,
8
+ getNodeId,
9
+ getNodeInit,
10
+ getNodeKey,
11
+ getNodeProperties,
12
+ getPropertyName,
13
+ identifierName,
14
+ offsetToLine,
15
+ parse,
16
+ walk,
17
+ } from '@ontrails/source';
18
+ import type { AstNode } from '@ontrails/source';
19
+ import type { WardenDiagnostic, WardenRule } from './types.js';
20
+
21
+ const RULE_NAME = 'owner-render-parity';
22
+
23
+ const HTTP_METHOD_DERIVATION_PATH = resolve(
24
+ fileURLToPath(new URL('../../../http/src/method.ts', import.meta.url))
25
+ );
26
+
27
+ const isTargetFile = (filePath: string): boolean =>
28
+ resolve(filePath) === HTTP_METHOD_DERIVATION_PATH;
29
+
30
+ const unwrapExpression = (node: AstNode | undefined): AstNode | undefined => {
31
+ let current = node;
32
+ while (
33
+ current &&
34
+ [
35
+ 'ParenthesizedExpression',
36
+ 'TSAsExpression',
37
+ 'TSNonNullExpression',
38
+ 'TSSatisfiesExpression',
39
+ 'TSTypeAssertion',
40
+ ].includes(current.type)
41
+ ) {
42
+ current = getNodeExpression(current);
43
+ }
44
+ return current;
45
+ };
46
+
47
+ interface IntentKeyMap {
48
+ readonly keys: ReadonlySet<string>;
49
+ readonly node: AstNode;
50
+ }
51
+
52
+ const findHttpMethodByIntentMap = (ast: AstNode): IntentKeyMap | null => {
53
+ let found: IntentKeyMap | null = null;
54
+
55
+ walk(ast, (node) => {
56
+ if (found || node.type !== 'VariableDeclarator') {
57
+ return;
58
+ }
59
+
60
+ const id = getNodeId(node);
61
+ const init = getNodeInit(node);
62
+ if (identifierName(id) !== 'httpMethodByIntent') {
63
+ return;
64
+ }
65
+
66
+ const objectExpression = unwrapExpression(init);
67
+ if (objectExpression?.type !== 'ObjectExpression') {
68
+ found = { keys: new Set(), node };
69
+ return;
70
+ }
71
+
72
+ const keys = new Set<string>();
73
+ for (const property of getNodeProperties(objectExpression) ?? []) {
74
+ if (property.type !== 'Property') {
75
+ continue;
76
+ }
77
+ const key = getPropertyName(getNodeKey(property));
78
+ if (key) {
79
+ keys.add(key);
80
+ }
81
+ }
82
+
83
+ found = { keys, node: objectExpression };
84
+ });
85
+
86
+ return found;
87
+ };
88
+
89
+ const buildMessage = (missing: string[], extra: string[]): string => {
90
+ const details = [
91
+ missing.length > 0 ? `missing owner intents: ${missing.join(', ')}` : '',
92
+ extra.length > 0 ? `unknown intentKeyMap keys: ${extra.join(', ')}` : '',
93
+ ].filter(Boolean);
94
+
95
+ return [
96
+ 'owner-render-parity: httpMethodByIntent must cover the core intentValues owner vocabulary.',
97
+ ...details,
98
+ ].join(' ');
99
+ };
100
+
101
+ export const ownerRenderParity: WardenRule = {
102
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
103
+ if (!isTargetFile(filePath)) {
104
+ return [];
105
+ }
106
+
107
+ const ast = parse(filePath, sourceCode);
108
+ if (!ast) {
109
+ return [];
110
+ }
111
+
112
+ const intentKeyMap = findHttpMethodByIntentMap(ast);
113
+ const ownerKeys = new Set<string>(intentValues);
114
+ const derivedKeys = intentKeyMap?.keys ?? new Set<string>();
115
+ const missing = [...ownerKeys]
116
+ .filter((key) => !derivedKeys.has(key))
117
+ .toSorted();
118
+ const extra = [...derivedKeys]
119
+ .filter((key) => !ownerKeys.has(key))
120
+ .toSorted();
121
+
122
+ if (intentKeyMap && missing.length === 0 && extra.length === 0) {
123
+ return [];
124
+ }
125
+
126
+ const node = intentKeyMap?.node ?? ast;
127
+ return [
128
+ {
129
+ filePath,
130
+ line: offsetToLine(sourceCode, node.start),
131
+ message: intentKeyMap
132
+ ? buildMessage(missing, extra)
133
+ : 'owner-render-parity: expected httpMethodByIntent to render core intentValues.',
134
+ rule: RULE_NAME,
135
+ severity: 'error',
136
+ },
137
+ ];
138
+ },
139
+ description:
140
+ 'Require owner-derived intentKeyMap maps to cover their authoritative owner vocabulary.',
141
+ name: RULE_NAME,
142
+ severity: 'error',
143
+ };
@@ -0,0 +1,25 @@
1
+ import type { Trail } from '@ontrails/core';
2
+ import { validatePermits } from '@ontrails/permits';
3
+
4
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
5
+
6
+ const toWardenDiagnostic = (
7
+ diagnostic: ReturnType<typeof validatePermits>[number]
8
+ ): WardenDiagnostic => ({
9
+ filePath: '<topo>',
10
+ line: 1,
11
+ message: diagnostic.message,
12
+ rule: `permit.${diagnostic.rule}`,
13
+ severity: diagnostic.severity,
14
+ });
15
+
16
+ export const permitGovernance: TopoAwareWardenRule = {
17
+ checkTopo: (topo) =>
18
+ validatePermits(
19
+ topo.list() as readonly Trail<unknown, unknown, unknown>[]
20
+ ).map(toWardenDiagnostic),
21
+ description:
22
+ 'Enforces permit declarations and scope hygiene across the compiled topo',
23
+ name: 'permit-governance',
24
+ severity: 'warn',
25
+ };
@@ -0,0 +1,141 @@
1
+ import type { WardenDiagnostic, WardenRule } from './types.js';
2
+ import { isTestFile } from './scan.js';
3
+ import {
4
+ findTrailLikeSpecs,
5
+ parseArrayEntries,
6
+ parseObjectProperties,
7
+ parseStringLiteral,
8
+ parseZodObjectShape,
9
+ } from './specs.js';
10
+
11
+ const REDUNDANT_OVERRIDE_KEYS = new Set(['label', 'options']);
12
+
13
+ const hasOnlyRedundantKeys = (
14
+ properties: ReadonlyMap<string, { value: string }>
15
+ ): boolean =>
16
+ [...properties.keys()].every((key) => REDUNDANT_OVERRIDE_KEYS.has(key));
17
+
18
+ const redundantLabelPart = (
19
+ derivedLabel: string,
20
+ properties: ReadonlyMap<string, { value: string }>
21
+ ): string[] => {
22
+ const label = parseStringLiteral(properties.get('label')?.value ?? '');
23
+ return label !== null && label === derivedLabel ? ['label'] : [];
24
+ };
25
+
26
+ const optionsAreDerivedDefaults = (
27
+ optionsText: string,
28
+ schemaOptions: readonly string[]
29
+ ): boolean => {
30
+ const entries = parseArrayEntries(optionsText, 0, optionsText);
31
+ if (entries.length !== schemaOptions.length) {
32
+ return false;
33
+ }
34
+
35
+ return entries.every((entry, index) => {
36
+ if (!entry.text.startsWith('{')) {
37
+ return false;
38
+ }
39
+
40
+ const properties = parseObjectProperties(entry.text, 0, entry.text);
41
+ if (properties.size !== 1) {
42
+ return false;
43
+ }
44
+
45
+ const value = parseStringLiteral(properties.get('value')?.value ?? '');
46
+ return value !== null && value === schemaOptions[index];
47
+ });
48
+ };
49
+
50
+ const redundantOptionsPart = (
51
+ options: { value: string } | undefined,
52
+ schemaOptions: readonly string[] | undefined
53
+ ): string[] =>
54
+ options !== undefined &&
55
+ schemaOptions !== undefined &&
56
+ optionsAreDerivedDefaults(options.value, schemaOptions)
57
+ ? ['options']
58
+ : [];
59
+
60
+ const findRedundantParts = (
61
+ fieldKey: string,
62
+ fieldOverride: string,
63
+ schemaText: string
64
+ ): string[] => {
65
+ const fieldInfo = parseZodObjectShape(schemaText).get(fieldKey);
66
+ if (!fieldInfo) {
67
+ return [];
68
+ }
69
+
70
+ const properties = parseObjectProperties(fieldOverride, 0, fieldOverride);
71
+ if (properties.size === 0) {
72
+ return ['field metadata'];
73
+ }
74
+
75
+ if (!hasOnlyRedundantKeys(properties)) {
76
+ return [];
77
+ }
78
+
79
+ const redundant = [
80
+ ...redundantLabelPart(fieldInfo.derivedLabel, properties),
81
+ ...redundantOptionsPart(properties.get('options'), fieldInfo.options),
82
+ ];
83
+
84
+ return redundant.length === properties.size ? redundant : [];
85
+ };
86
+
87
+ const diagnosticsForSpec = (
88
+ sourceCode: string,
89
+ filePath: string,
90
+ spec: ReturnType<typeof findTrailLikeSpecs>[number]
91
+ ): readonly WardenDiagnostic[] => {
92
+ const input = spec.properties.get('input');
93
+ const fields = spec.properties.get('fields');
94
+ if (!input || !fields) {
95
+ return [];
96
+ }
97
+
98
+ const fieldEntries = parseObjectProperties(
99
+ fields.value,
100
+ fields.start,
101
+ sourceCode
102
+ );
103
+ return [...fieldEntries.entries()]
104
+ .map(([fieldKey, fieldEntry]) => ({
105
+ fieldEntry,
106
+ fieldKey,
107
+ redundantParts: findRedundantParts(
108
+ fieldKey,
109
+ fieldEntry.value,
110
+ input.value
111
+ ),
112
+ }))
113
+ .filter(({ redundantParts }) => redundantParts.length > 0)
114
+ .map(({ fieldEntry, fieldKey, redundantParts }) => ({
115
+ filePath,
116
+ line: fieldEntry.line,
117
+ message: `Trail "${spec.id}" field "${fieldKey}" only repeats schema-derived ${redundantParts.join(' and ')}. Remove the override and let deriveFields() infer it.`,
118
+ rule: 'prefer-schema-inference',
119
+ severity: 'warn' as const,
120
+ }));
121
+ };
122
+
123
+ /**
124
+ * Warns when a fields override only repeats metadata deriveFields() already gets from
125
+ * the schema.
126
+ */
127
+ export const preferSchemaInference: WardenRule = {
128
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
129
+ if (isTestFile(filePath)) {
130
+ return [];
131
+ }
132
+
133
+ return findTrailLikeSpecs(sourceCode).flatMap((spec) =>
134
+ diagnosticsForSpec(sourceCode, filePath, spec)
135
+ );
136
+ },
137
+ description:
138
+ 'Warn when fields overrides only restate labels or enum options deriveFields() already infers from the Zod schema.',
139
+ name: 'prefer-schema-inference',
140
+ severity: 'warn',
141
+ };