@ontrails/warden 1.0.0-beta.23 → 1.0.0-beta.29

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 (61) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +35 -1
  3. package/package.json +10 -9
  4. package/src/ast.ts +109 -0
  5. package/src/cli.ts +44 -4
  6. package/src/command.ts +24 -34
  7. package/src/drift.ts +24 -8
  8. package/src/index.ts +9 -0
  9. package/src/project-rules.ts +290 -0
  10. package/src/resolve.ts +8 -7
  11. package/src/rules/ast.ts +837 -6
  12. package/src/rules/cli-command-route-coherence.ts +177 -0
  13. package/src/rules/composes-declarations.ts +210 -77
  14. package/src/rules/context-no-surface-types.ts +15 -12
  15. package/src/rules/contour-exists.ts +7 -7
  16. package/src/rules/dead-internal-trail.ts +11 -4
  17. package/src/rules/dead-public-trail.ts +258 -0
  18. package/src/rules/duplicate-public-contract.ts +91 -0
  19. package/src/rules/error-mapping-completeness.ts +5 -3
  20. package/src/rules/example-valid.ts +6 -12
  21. package/src/rules/fires-declarations.ts +41 -64
  22. package/src/rules/implementation-returns-result.ts +208 -76
  23. package/src/rules/incomplete-crud.ts +20 -19
  24. package/src/rules/index.ts +15 -0
  25. package/src/rules/layer-field-name-drift.ts +12 -6
  26. package/src/rules/library-projection-coherence.ts +100 -0
  27. package/src/rules/metadata.ts +102 -1
  28. package/src/rules/no-destructured-compose.ts +16 -14
  29. package/src/rules/no-native-error-result.ts +15 -8
  30. package/src/rules/no-redundant-result-error-wrap.ts +82 -29
  31. package/src/rules/no-sync-result-assumption.ts +70 -68
  32. package/src/rules/no-top-level-surface.ts +46 -64
  33. package/src/rules/on-references-exist.ts +1 -1
  34. package/src/rules/owner-projection-parity.ts +10 -13
  35. package/src/rules/public-export-example-coverage.ts +29 -20
  36. package/src/rules/public-internal-deep-imports.ts +2 -2
  37. package/src/rules/read-intent-fires.ts +8 -8
  38. package/src/rules/registry-names.ts +10 -0
  39. package/src/rules/resource-declarations.ts +20 -31
  40. package/src/rules/resource-exists.ts +2 -2
  41. package/src/rules/resource-id-grammar.ts +2 -2
  42. package/src/rules/resource-mock-coverage.ts +2 -2
  43. package/src/rules/static-resource-accessor-preference.ts +26 -34
  44. package/src/rules/surface-facet-coherence.ts +21 -29
  45. package/src/rules/trail-fork-coaching.ts +616 -0
  46. package/src/rules/trail-versioning-source.ts +56 -78
  47. package/src/rules/types.ts +2 -0
  48. package/src/rules/unreachable-detour-shadowing.ts +16 -21
  49. package/src/rules/valid-describe-refs.ts +14 -12
  50. package/src/rules/warden-export-symmetry.ts +42 -35
  51. package/src/rules/warden-rules-use-ast.ts +168 -50
  52. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  53. package/src/trails/dead-public-trail.trail.ts +31 -0
  54. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  55. package/src/trails/error-mapping-completeness.trail.ts +1 -0
  56. package/src/trails/index.ts +5 -0
  57. package/src/trails/library-projection-coherence.trail.ts +43 -0
  58. package/src/trails/schema.ts +4 -0
  59. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  60. package/src/trails/warden-rules-use-ast.trail.ts +19 -0
  61. package/src/trails/wrap-rule.ts +1 -0
@@ -3,15 +3,18 @@ import {
3
3
  collectContourDefinitionIds,
4
4
  collectImportAliasMap,
5
5
  collectNamedContourIds,
6
+ deriveContourIdentifierName,
6
7
  extractFirstStringArg,
7
8
  findConfigProperty,
8
9
  findTrailDefinitions,
10
+ getNodeCallee,
11
+ getNodeObject,
12
+ getNodeProperty,
9
13
  identifierName,
10
14
  isMemberAccessNonComputed,
11
15
  isUserNamespaceReceiverAllowed,
12
16
  offsetToLine,
13
17
  parse,
14
- deriveContourIdentifierName,
15
18
  } from './ast.js';
16
19
  import type { AstNode, TrailDefinition, UserNamespaceContext } from './ast.js';
17
20
  import { mergeKnownContourIds } from './contour-ids.js';
@@ -24,8 +27,7 @@ import type {
24
27
 
25
28
  const isContourCall = (node: AstNode): boolean =>
26
29
  node.type === 'CallExpression' &&
27
- identifierName((node as unknown as { callee?: AstNode }).callee) ===
28
- 'contour';
30
+ identifierName(getNodeCallee(node)) === 'contour';
29
31
 
30
32
  const getContourElements = (config: AstNode): readonly AstNode[] => {
31
33
  const contoursProp = findConfigProperty(config, 'contours');
@@ -58,10 +60,8 @@ const resolveNamespaceMemberContourName = (
58
60
  if (!isMemberAccessNonComputed(element)) {
59
61
  return null;
60
62
  }
61
- const { object, property } = element as unknown as {
62
- readonly object?: AstNode;
63
- readonly property?: AstNode;
64
- };
63
+ const object = getNodeObject(element);
64
+ const property = getNodeProperty(element);
65
65
  const receiver = object ? identifierName(object) : null;
66
66
  if (
67
67
  !receiver ||
@@ -2,6 +2,7 @@ import {
2
2
  collectComposeTargetTrailIds,
3
3
  findConfigProperty,
4
4
  findTrailDefinitions,
5
+ getNodeValue,
5
6
  getStringValue,
6
7
  isStringLiteral,
7
8
  offsetToLine,
@@ -56,7 +57,7 @@ const hasLegacyMetaInternal = (config: AstNode): boolean => {
56
57
  const internalValue = internalProp?.value as AstNode | undefined;
57
58
  return (
58
59
  internalValue?.type === 'BooleanLiteral' &&
59
- (internalValue as unknown as { value: boolean }).value === true
60
+ getNodeValue(internalValue) === true
60
61
  );
61
62
  };
62
63
 
@@ -79,7 +80,8 @@ const checkDeadInternalTrails = (
79
80
  ast: AstNode | null,
80
81
  sourceCode: string,
81
82
  filePath: string,
82
- composedTrailIds: ReadonlySet<string>
83
+ composedTrailIds: ReadonlySet<string>,
84
+ topoTrailIds?: ReadonlySet<string> | undefined
83
85
  ): readonly WardenDiagnostic[] => {
84
86
  if (isTestFile(filePath) || !ast) {
85
87
  return [];
@@ -92,7 +94,11 @@ const checkDeadInternalTrails = (
92
94
  continue;
93
95
  }
94
96
 
95
- if (hasOnActivation(def.config) || composedTrailIds.has(def.id)) {
97
+ if (
98
+ hasOnActivation(def.config) ||
99
+ composedTrailIds.has(def.id) ||
100
+ topoTrailIds?.has(def.id)
101
+ ) {
96
102
  continue;
97
103
  }
98
104
 
@@ -144,7 +150,8 @@ export const deadInternalTrail: ProjectAwareWardenRule = {
144
150
  ast,
145
151
  sourceCode,
146
152
  filePath,
147
- composeTargetTrailIds
153
+ composeTargetTrailIds,
154
+ context.topoTrailIds
148
155
  );
149
156
  },
150
157
  description:
@@ -0,0 +1,258 @@
1
+ import {
2
+ collectComposeTargetTrailIds,
3
+ findConfigProperty,
4
+ findTrailDefinitions,
5
+ getNodeDeclaration,
6
+ getNodeDeclarations,
7
+ getNodeId,
8
+ getNodeInit,
9
+ getNodeLocal,
10
+ getNodeName,
11
+ getNodeSpecifiers,
12
+ getNodeValue,
13
+ getStringValue,
14
+ isStringLiteral,
15
+ offsetToLine,
16
+ parse,
17
+ walkWithParents,
18
+ } from './ast.js';
19
+ import type { AstNode, AstParentContext } from './ast.js';
20
+ import { isTestFile } from './scan.js';
21
+ import type {
22
+ ProjectAwareWardenRule,
23
+ ProjectContext,
24
+ WardenDiagnostic,
25
+ } from './types.js';
26
+
27
+ const RULE_NAME = 'dead-public-trail';
28
+
29
+ const isNonEmptyActivationValue = (onValue: AstNode): boolean => {
30
+ if (onValue.type === 'Identifier') {
31
+ return true;
32
+ }
33
+ if (onValue.type !== 'ArrayExpression') {
34
+ return false;
35
+ }
36
+ const elements = onValue['elements'] as readonly AstNode[] | undefined;
37
+ return (elements?.length ?? 0) > 0;
38
+ };
39
+
40
+ const hasOnActivation = (config: AstNode): boolean => {
41
+ const onProp = findConfigProperty(config, 'on');
42
+ const onValue = onProp?.value as AstNode | undefined;
43
+ return onValue ? isNonEmptyActivationValue(onValue) : false;
44
+ };
45
+
46
+ const hasExplicitInternalVisibility = (config: AstNode): boolean => {
47
+ const visibilityProp = findConfigProperty(config, 'visibility');
48
+ const visibilityValue = visibilityProp?.value as AstNode | undefined;
49
+ return (
50
+ !!visibilityValue &&
51
+ isStringLiteral(visibilityValue) &&
52
+ getStringValue(visibilityValue) === 'internal'
53
+ );
54
+ };
55
+
56
+ const hasLegacyMetaInternal = (config: AstNode): boolean => {
57
+ const metaProp = findConfigProperty(config, 'meta');
58
+ const metaValue = metaProp?.value as AstNode | undefined;
59
+ if (!metaValue || metaValue.type !== 'ObjectExpression') {
60
+ return false;
61
+ }
62
+ const internalProp = findConfigProperty(metaValue, 'internal');
63
+ const internalValue = internalProp?.value as AstNode | undefined;
64
+ return (
65
+ internalValue?.type === 'BooleanLiteral' &&
66
+ getNodeValue(internalValue) === true
67
+ );
68
+ };
69
+
70
+ const isInternalTrail = (config: AstNode): boolean =>
71
+ hasExplicitInternalVisibility(config) || hasLegacyMetaInternal(config);
72
+
73
+ const isExportDeclaration = (node: AstNode | null | undefined): boolean =>
74
+ node?.type === 'ExportNamedDeclaration' ||
75
+ node?.type === 'ExportDefaultDeclaration';
76
+
77
+ const identifierName = (node: AstNode | undefined): string | null =>
78
+ node?.type === 'Identifier' ? (getNodeName(node) ?? null) : null;
79
+
80
+ const trailBindingStarts = (ast: AstNode): ReadonlyMap<string, number> => {
81
+ const trailStarts = new Set(
82
+ findTrailDefinitions(ast)
83
+ .filter((definition) => definition.kind === 'trail')
84
+ .map((definition) => definition.start)
85
+ );
86
+ const bindings = new Map<string, number>();
87
+
88
+ walkWithParents(ast, (node: AstNode) => {
89
+ if (node.type !== 'VariableDeclarator') {
90
+ return;
91
+ }
92
+ const id = getNodeId(node);
93
+ const init = getNodeInit(node);
94
+ const name = identifierName(id);
95
+ if (name && init && trailStarts.has(init.start)) {
96
+ bindings.set(name, init.start);
97
+ }
98
+ });
99
+
100
+ return bindings;
101
+ };
102
+
103
+ const addExportedSpecifierStarts = (
104
+ node: AstNode,
105
+ bindings: ReadonlyMap<string, number>,
106
+ exported: Set<number>
107
+ ): void => {
108
+ const specifiers = getNodeSpecifiers(node) ?? [];
109
+ for (const specifier of specifiers) {
110
+ const local = getNodeLocal(specifier);
111
+ const name = identifierName(local);
112
+ const start = name ? bindings.get(name) : undefined;
113
+ if (start !== undefined) {
114
+ exported.add(start);
115
+ }
116
+ }
117
+ };
118
+
119
+ const exportedTrailStarts = (ast: AstNode): ReadonlySet<number> => {
120
+ const exported = new Set<number>();
121
+ const bindings = trailBindingStarts(ast);
122
+
123
+ walkWithParents(ast, (node: AstNode, context: AstParentContext) => {
124
+ if (node.type !== 'CallExpression') {
125
+ return;
126
+ }
127
+
128
+ // Catch `export default trail(...)`. Exported variable declarations are
129
+ // handled by the VariableDeclaration walk below.
130
+ if (isExportDeclaration(context.parent)) {
131
+ exported.add(node.start);
132
+ }
133
+ });
134
+
135
+ walkWithParents(ast, (node: AstNode, context: AstParentContext) => {
136
+ if (node.type !== 'VariableDeclaration') {
137
+ return;
138
+ }
139
+ if (!isExportDeclaration(context.parent)) {
140
+ return;
141
+ }
142
+ const declarations = getNodeDeclarations(node) ?? [];
143
+ for (const declaration of declarations) {
144
+ const init = getNodeInit(declaration);
145
+ if (init?.type === 'CallExpression') {
146
+ exported.add(init.start);
147
+ }
148
+ }
149
+ });
150
+
151
+ walkWithParents(ast, (node: AstNode) => {
152
+ if (node.type === 'ExportNamedDeclaration') {
153
+ addExportedSpecifierStarts(node, bindings, exported);
154
+ return;
155
+ }
156
+
157
+ if (node.type !== 'ExportDefaultDeclaration') {
158
+ return;
159
+ }
160
+ const declaration = getNodeDeclaration(node);
161
+ const name = identifierName(declaration);
162
+ const start = name ? bindings.get(name) : undefined;
163
+ if (start !== undefined) {
164
+ exported.add(start);
165
+ }
166
+ });
167
+
168
+ return exported;
169
+ };
170
+
171
+ const buildDiagnostic = (
172
+ trailId: string,
173
+ filePath: string,
174
+ line: number
175
+ ): WardenDiagnostic => ({
176
+ filePath,
177
+ line,
178
+ message: `Exported public trail "${trailId}" is not registered in a configured app topo, composed by another trail, or activated by on:. Anchor the contract in a topo, compose it, mark it internal, or remove the public export.`,
179
+ rule: RULE_NAME,
180
+ severity: 'warn',
181
+ });
182
+
183
+ const unionComposeTargetIds = (
184
+ ast: AstNode | null,
185
+ sourceCode: string,
186
+ context: ProjectContext
187
+ ): ReadonlySet<string> => {
188
+ const local = ast
189
+ ? collectComposeTargetTrailIds(ast, sourceCode)
190
+ : new Set<string>();
191
+ return context.composeTargetTrailIds
192
+ ? new Set([...context.composeTargetTrailIds, ...local])
193
+ : local;
194
+ };
195
+
196
+ const checkDeadPublicTrails = (
197
+ ast: AstNode | null,
198
+ sourceCode: string,
199
+ filePath: string,
200
+ context: ProjectContext
201
+ ): readonly WardenDiagnostic[] => {
202
+ if (isTestFile(filePath) || !ast || !context.topoTrailIds) {
203
+ return [];
204
+ }
205
+
206
+ const exportedStarts = exportedTrailStarts(ast);
207
+ const composeTargetTrailIds = unionComposeTargetIds(ast, sourceCode, context);
208
+ const diagnostics: WardenDiagnostic[] = [];
209
+
210
+ for (const def of findTrailDefinitions(ast)) {
211
+ if (
212
+ def.kind !== 'trail' ||
213
+ isInternalTrail(def.config) ||
214
+ !exportedStarts.has(def.start)
215
+ ) {
216
+ continue;
217
+ }
218
+
219
+ if (
220
+ hasOnActivation(def.config) ||
221
+ composeTargetTrailIds.has(def.id) ||
222
+ context.topoTrailIds.has(def.id)
223
+ ) {
224
+ continue;
225
+ }
226
+
227
+ diagnostics.push(
228
+ buildDiagnostic(def.id, filePath, offsetToLine(sourceCode, def.start))
229
+ );
230
+ }
231
+
232
+ return diagnostics;
233
+ };
234
+
235
+ export const deadPublicTrail: ProjectAwareWardenRule = {
236
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
237
+ const ast = parse(filePath, sourceCode);
238
+ return checkDeadPublicTrails(ast, sourceCode, filePath, {
239
+ knownTrailIds: new Set<string>(),
240
+ });
241
+ },
242
+ checkWithContext(
243
+ sourceCode: string,
244
+ filePath: string,
245
+ context: ProjectContext
246
+ ): readonly WardenDiagnostic[] {
247
+ return checkDeadPublicTrails(
248
+ parse(filePath, sourceCode),
249
+ sourceCode,
250
+ filePath,
251
+ context
252
+ );
253
+ },
254
+ description:
255
+ 'Warn when an exported public trail is neither registered in configured app topos nor reachable through composition or activation.',
256
+ name: RULE_NAME,
257
+ severity: 'warn',
258
+ };
@@ -0,0 +1,91 @@
1
+ import { deriveTopoGraph } from '@ontrails/topographer';
2
+ import type { TopoGraph, TopoGraphEntry } from '@ontrails/topographer';
3
+
4
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
5
+
6
+ const RULE_NAME = 'duplicate-public-contract';
7
+ const TOPO_FILE = '<topo>';
8
+
9
+ const resolveGraph = (
10
+ topo: Parameters<TopoAwareWardenRule['checkTopo']>[0],
11
+ graph: TopoGraph | undefined
12
+ ): TopoGraph => graph ?? deriveTopoGraph(topo);
13
+
14
+ const canonicalize = (value: unknown): unknown => {
15
+ if (Array.isArray(value)) {
16
+ return value.map(canonicalize);
17
+ }
18
+ if (value && typeof value === 'object') {
19
+ return Object.fromEntries(
20
+ Object.entries(value as Record<string, unknown>)
21
+ .filter(([, entryValue]) => entryValue !== undefined)
22
+ .toSorted(([left], [right]) => left.localeCompare(right))
23
+ .map(([key, entryValue]) => [key, canonicalize(entryValue)])
24
+ );
25
+ }
26
+ return value;
27
+ };
28
+
29
+ const contractFingerprint = (entry: TopoGraphEntry): string =>
30
+ JSON.stringify(
31
+ canonicalize({
32
+ composes: entry.composes,
33
+ detours: entry.detours,
34
+ dryRunCapable: entry.dryRunCapable,
35
+ fires: entry.fires,
36
+ idempotent: entry.idempotent,
37
+ input: entry.input,
38
+ intent: entry.intent,
39
+ meta: entry.meta,
40
+ on: entry.on,
41
+ output: entry.output,
42
+ permit: entry.permit,
43
+ resources: entry.resources,
44
+ })
45
+ );
46
+
47
+ const isCandidate = (entry: TopoGraphEntry): boolean =>
48
+ entry.kind === 'trail' &&
49
+ !entry.id.startsWith('warden.rule.') &&
50
+ entry.deprecated !== true &&
51
+ entry.meta?.['internal'] !== true &&
52
+ entry.input !== undefined &&
53
+ entry.output !== undefined &&
54
+ Boolean(entry.cli || entry.surfaces.length > 0);
55
+
56
+ const renderTrailIds = (trailIds: readonly string[]): string =>
57
+ trailIds
58
+ .toSorted((left, right) => left.localeCompare(right))
59
+ .map((trailId) => `"${trailId}"`)
60
+ .join(', ');
61
+
62
+ const buildDiagnostic = (trailIds: readonly string[]): WardenDiagnostic => ({
63
+ filePath: TOPO_FILE,
64
+ line: 1,
65
+ message: `Likely duplicate public trail contracts ${renderTrailIds(trailIds)} share the same input, output, intent, permits, resources, composes, signals, and detours. Keep one contract with aliases/input mappings, compose a distinct wrapper, or document why these public contracts are separate.`,
66
+ rule: RULE_NAME,
67
+ severity: 'warn',
68
+ });
69
+
70
+ export const duplicatePublicContract: TopoAwareWardenRule = {
71
+ checkTopo(topo, context) {
72
+ const graph = resolveGraph(topo, context?.graph);
73
+ const groups = new Map<string, string[]>();
74
+
75
+ for (const entry of graph.entries) {
76
+ if (!isCandidate(entry)) {
77
+ continue;
78
+ }
79
+ const key = contractFingerprint(entry);
80
+ groups.set(key, [...(groups.get(key) ?? []), entry.id]);
81
+ }
82
+
83
+ return [...groups.values()]
84
+ .filter((trailIds) => trailIds.length > 1)
85
+ .map(buildDiagnostic);
86
+ },
87
+ description:
88
+ 'Warn when public surface trails expose the same normalized contract facts.',
89
+ name: RULE_NAME,
90
+ severity: 'warn',
91
+ };
@@ -10,6 +10,8 @@ import { codesByCategory, errorClasses } from '@ontrails/core';
10
10
  import type { ErrorCategory } from '@ontrails/core';
11
11
 
12
12
  import {
13
+ getNodeKey,
14
+ getNodeValueNode,
13
15
  getStringValue,
14
16
  identifierName,
15
17
  isStringLiteral,
@@ -82,9 +84,9 @@ const findObjectPropertyValue = (
82
84
  continue;
83
85
  }
84
86
 
85
- const key = getPropertyName((property as unknown as { key?: AstNode }).key);
87
+ const key = getPropertyName(getNodeKey(property));
86
88
  if (key === propertyName) {
87
- return (property as unknown as { value?: AstNode }).value ?? null;
89
+ return getNodeValueNode(property) ?? null;
88
90
  }
89
91
  }
90
92
 
@@ -157,7 +159,7 @@ const addMappedCategory = (
157
159
  return true;
158
160
  }
159
161
 
160
- const key = getPropertyName((property as unknown as { key?: AstNode }).key);
162
+ const key = getPropertyName(getNodeKey(property));
161
163
  if (key) {
162
164
  categories.add(key);
163
165
  }
@@ -5,6 +5,9 @@ import {
5
5
  extractStringLiteral,
6
6
  findConfigProperty,
7
7
  findContourDefinitions,
8
+ getNodeExpression,
9
+ getNodeObject,
10
+ getNodeProperty,
8
11
  getStringValue,
9
12
  identifierName,
10
13
  offsetToLine,
@@ -115,11 +118,7 @@ const evaluateIdentifierExpression: ContourNodeEvaluator = (
115
118
  const evaluateWrappedExpression: ContourNodeEvaluator = (
116
119
  node: AstNode,
117
120
  env: ContourEvaluationEnvironment
118
- ): unknown =>
119
- evaluateNode(
120
- requireNode((node as unknown as { expression?: AstNode }).expression),
121
- env
122
- );
121
+ ): unknown => evaluateNode(requireNode(getNodeExpression(node)), env);
123
122
 
124
123
  const evaluateNullExpression: ContourNodeEvaluator = (): null => null;
125
124
 
@@ -215,13 +214,8 @@ const evaluateMemberCall = (
215
214
  args: readonly unknown[],
216
215
  env: ContourEvaluationEnvironment
217
216
  ): unknown => {
218
- const receiver = evaluateNode(
219
- requireNode((callee as unknown as { object?: AstNode }).object),
220
- env
221
- );
222
- const propertyName = getPropertyName(
223
- (callee as unknown as { property?: AstNode }).property
224
- );
217
+ const receiver = evaluateNode(requireNode(getNodeObject(callee)), env);
218
+ const propertyName = getPropertyName(getNodeProperty(callee));
225
219
  if (!propertyName) {
226
220
  throw new UnsupportedContourEvaluationError(
227
221
  'Unsupported member property in contour evaluation.'