@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.
- package/CHANGELOG.md +113 -0
- package/README.md +35 -1
- package/package.json +10 -9
- package/src/ast.ts +109 -0
- package/src/cli.ts +44 -4
- package/src/command.ts +24 -34
- package/src/drift.ts +24 -8
- package/src/index.ts +9 -0
- package/src/project-rules.ts +290 -0
- package/src/resolve.ts +8 -7
- package/src/rules/ast.ts +837 -6
- package/src/rules/cli-command-route-coherence.ts +177 -0
- package/src/rules/composes-declarations.ts +210 -77
- package/src/rules/context-no-surface-types.ts +15 -12
- package/src/rules/contour-exists.ts +7 -7
- package/src/rules/dead-internal-trail.ts +11 -4
- package/src/rules/dead-public-trail.ts +258 -0
- package/src/rules/duplicate-public-contract.ts +91 -0
- package/src/rules/error-mapping-completeness.ts +5 -3
- package/src/rules/example-valid.ts +6 -12
- package/src/rules/fires-declarations.ts +41 -64
- package/src/rules/implementation-returns-result.ts +208 -76
- package/src/rules/incomplete-crud.ts +20 -19
- package/src/rules/index.ts +15 -0
- package/src/rules/layer-field-name-drift.ts +12 -6
- package/src/rules/library-projection-coherence.ts +100 -0
- package/src/rules/metadata.ts +102 -1
- package/src/rules/no-destructured-compose.ts +16 -14
- package/src/rules/no-native-error-result.ts +15 -8
- package/src/rules/no-redundant-result-error-wrap.ts +82 -29
- package/src/rules/no-sync-result-assumption.ts +70 -68
- package/src/rules/no-top-level-surface.ts +46 -64
- package/src/rules/on-references-exist.ts +1 -1
- package/src/rules/owner-projection-parity.ts +10 -13
- package/src/rules/public-export-example-coverage.ts +29 -20
- package/src/rules/public-internal-deep-imports.ts +2 -2
- package/src/rules/read-intent-fires.ts +8 -8
- package/src/rules/registry-names.ts +10 -0
- package/src/rules/resource-declarations.ts +20 -31
- package/src/rules/resource-exists.ts +2 -2
- package/src/rules/resource-id-grammar.ts +2 -2
- package/src/rules/resource-mock-coverage.ts +2 -2
- package/src/rules/static-resource-accessor-preference.ts +26 -34
- package/src/rules/surface-facet-coherence.ts +21 -29
- package/src/rules/trail-fork-coaching.ts +616 -0
- package/src/rules/trail-versioning-source.ts +56 -78
- package/src/rules/types.ts +2 -0
- package/src/rules/unreachable-detour-shadowing.ts +16 -21
- package/src/rules/valid-describe-refs.ts +14 -12
- package/src/rules/warden-export-symmetry.ts +42 -35
- package/src/rules/warden-rules-use-ast.ts +168 -50
- package/src/trails/cli-command-route-coherence.trail.ts +47 -0
- package/src/trails/dead-public-trail.trail.ts +31 -0
- package/src/trails/duplicate-public-contract.trail.ts +47 -0
- package/src/trails/error-mapping-completeness.trail.ts +1 -0
- package/src/trails/index.ts +5 -0
- package/src/trails/library-projection-coherence.trail.ts +43 -0
- package/src/trails/schema.ts +4 -0
- package/src/trails/trail-fork-coaching.trail.ts +42 -0
- package/src/trails/warden-rules-use-ast.trail.ts +19 -0
- 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
|
|
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
|
|
62
|
-
|
|
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
|
|
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 (
|
|
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
|
|
87
|
+
const key = getPropertyName(getNodeKey(property));
|
|
86
88
|
if (key === propertyName) {
|
|
87
|
-
return (property
|
|
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
|
|
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
|
-
|
|
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.'
|