@ontrails/warden 1.0.0-beta.3 → 1.0.0-beta.30
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 +625 -9
- package/README.md +111 -26
- package/bin/warden.ts +50 -0
- package/package.json +28 -5
- package/src/adapter-check.ts +136 -0
- package/src/ast.ts +137 -0
- package/src/cli.ts +1415 -104
- package/src/command.ts +943 -0
- package/src/config.ts +184 -0
- package/src/draft.ts +22 -0
- package/src/drift.ts +122 -22
- package/src/fix.ts +120 -0
- package/src/formatters.ts +79 -9
- package/src/guide.ts +245 -0
- package/src/index.ts +215 -14
- package/src/project-context.ts +163 -0
- package/src/project-rules.ts +290 -0
- package/src/resolve.ts +531 -0
- package/src/rules/activation-orphan.ts +97 -0
- package/src/rules/ast.ts +4008 -86
- package/src/rules/circular-refs.ts +154 -0
- package/src/rules/cli-command-route-coherence.ts +177 -0
- package/src/rules/composes-declarations.ts +837 -0
- package/src/rules/context-no-surface-types.ts +78 -15
- package/src/rules/contour-exists.ts +251 -0
- package/src/rules/contour-ids.ts +15 -0
- package/src/rules/dead-internal-trail.ts +161 -0
- package/src/rules/dead-public-trail.ts +258 -0
- package/src/rules/draft-file-marking.ts +160 -0
- package/src/rules/draft-visible-debt.ts +87 -0
- package/src/rules/duplicate-public-contract.ts +91 -0
- package/src/rules/error-mapping-completeness.ts +290 -0
- package/src/rules/example-valid.ts +395 -0
- package/src/rules/fires-declarations.ts +735 -0
- package/src/rules/implementation-returns-result.ts +1411 -166
- package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
- package/src/rules/incomplete-crud.ts +581 -0
- package/src/rules/index.ts +234 -18
- package/src/rules/intent-propagation.ts +127 -0
- package/src/rules/layer-field-name-drift.ts +102 -0
- package/src/rules/library-projection-coherence.ts +100 -0
- package/src/rules/metadata.ts +755 -0
- package/src/rules/missing-reconcile.ts +98 -0
- package/src/rules/missing-visibility.ts +110 -0
- package/src/rules/no-destructured-compose.ts +194 -0
- package/src/rules/no-dev-permit-in-source.ts +99 -0
- package/src/rules/no-direct-implementation-call.ts +7 -7
- package/src/rules/no-legacy-layer-imports.ts +211 -0
- package/src/rules/no-native-error-result.ts +118 -0
- package/src/rules/no-redundant-result-error-wrap.ts +384 -0
- package/src/rules/no-retired-cross-vocabulary.ts +194 -0
- package/src/rules/no-sync-result-assumption.ts +1135 -98
- package/src/rules/no-throw-in-detour-recover.ts +225 -0
- package/src/rules/no-throw-in-implementation.ts +10 -9
- package/src/rules/no-top-level-surface.ts +371 -0
- package/src/rules/on-references-exist.ts +194 -0
- package/src/rules/orphaned-signal.ts +150 -0
- package/src/rules/owner-projection-parity.ts +143 -0
- package/src/rules/permit-governance.ts +25 -0
- package/src/rules/public-export-example-coverage.ts +562 -0
- package/src/rules/public-internal-deep-imports.ts +517 -0
- package/src/rules/public-output-schema.ts +29 -0
- package/src/rules/public-union-output-discriminants.ts +150 -0
- package/src/rules/read-intent-fires.ts +187 -0
- package/src/rules/reference-exists.ts +98 -0
- package/src/rules/registry-names.ts +155 -0
- package/src/rules/resolved-import-boundary.ts +146 -0
- package/src/rules/resource-declarations.ts +693 -0
- package/src/rules/resource-exists.ts +179 -0
- package/src/rules/resource-id-grammar.ts +65 -0
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/scan.ts +38 -25
- package/src/rules/scheduled-destroy-intent.ts +44 -0
- package/src/rules/signal-graph-coaching.ts +191 -0
- package/src/rules/specs.ts +9 -5
- package/src/rules/static-resource-accessor-preference.ts +649 -0
- package/src/rules/surface-facet-coherence.ts +362 -0
- package/src/rules/trail-fork-coaching.ts +616 -0
- package/src/rules/trail-versioning-source.ts +1072 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/types.ts +272 -6
- package/src/rules/unmaterialized-activation-source.ts +84 -0
- package/src/rules/unreachable-detour-shadowing.ts +339 -0
- package/src/rules/valid-describe-refs.ts +162 -32
- package/src/rules/valid-detour-contract.ts +78 -0
- package/src/rules/warden-export-symmetry.ts +540 -0
- package/src/rules/warden-rules-use-ast.ts +1114 -0
- package/src/rules/webhook-route-collision.ts +243 -0
- package/src/trails/activation-orphan.trail.ts +84 -0
- package/src/trails/circular-refs.trail.ts +29 -0
- package/src/trails/cli-command-route-coherence.trail.ts +47 -0
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/context-no-surface-types.trail.ts +21 -0
- package/src/trails/contour-exists.trail.ts +21 -0
- package/src/trails/dead-internal-trail.trail.ts +26 -0
- package/src/trails/dead-public-trail.trail.ts +31 -0
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/draft-file-marking.trail.ts +16 -0
- package/src/trails/draft-visible-debt.trail.ts +16 -0
- package/src/trails/duplicate-public-contract.trail.ts +47 -0
- package/src/trails/error-mapping-completeness.trail.ts +30 -0
- package/src/trails/example-valid.trail.ts +25 -0
- package/src/trails/fires-declarations.trail.ts +23 -0
- package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
- package/src/trails/implementation-returns-result.trail.ts +20 -0
- package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
- package/src/trails/incomplete-crud.trail.ts +39 -0
- package/src/trails/index.ts +83 -0
- package/src/trails/intent-propagation.trail.ts +30 -0
- package/src/trails/layer-field-name-drift.trail.ts +39 -0
- package/src/trails/library-projection-coherence.trail.ts +43 -0
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-reconcile.trail.ts +33 -0
- package/src/trails/missing-visibility.trail.ts +22 -0
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
- package/src/trails/no-direct-implementation-call.trail.ts +16 -0
- package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
- package/src/trails/no-native-error-result.trail.ts +18 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
- package/src/trails/no-sync-result-assumption.trail.ts +19 -0
- package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
- package/src/trails/no-throw-in-implementation.trail.ts +20 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/on-references-exist.trail.ts +21 -0
- package/src/trails/orphaned-signal.trail.ts +36 -0
- package/src/trails/owner-projection-parity.trail.ts +26 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/permit-governance.trail.ts +51 -0
- package/src/trails/prefer-schema-inference.trail.ts +21 -0
- package/src/trails/public-export-example-coverage.trail.ts +16 -0
- package/src/trails/public-internal-deep-imports.trail.ts +94 -0
- package/src/trails/public-output-schema.trail.ts +55 -0
- package/src/trails/public-union-output-discriminants.trail.ts +33 -0
- package/src/trails/read-intent-fires.trail.ts +20 -0
- package/src/trails/reference-exists.trail.ts +25 -0
- package/src/trails/resolved-import-boundary.trail.ts +109 -0
- package/src/trails/resource-declarations.trail.ts +25 -0
- package/src/trails/resource-exists.trail.ts +27 -0
- package/src/trails/resource-id-grammar.trail.ts +39 -0
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +162 -0
- package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
- package/src/trails/schema.ts +198 -0
- package/src/trails/signal-graph-coaching.trail.ts +77 -0
- package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
- package/src/trails/surface-facet-coherence.trail.ts +25 -0
- package/src/trails/topo.ts +6 -0
- package/src/trails/trail-fork-coaching.trail.ts +42 -0
- package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
- package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
- package/src/trails/valid-describe-refs.trail.ts +18 -0
- package/src/trails/valid-detour-contract.trail.ts +71 -0
- package/src/trails/version-gap.trail.ts +35 -0
- package/src/trails/version-pinned-compose.trail.ts +23 -0
- package/src/trails/version-without-examples.trail.ts +38 -0
- package/src/trails/warden-export-symmetry.trail.ts +16 -0
- package/src/trails/warden-rules-use-ast.trail.ts +64 -0
- package/src/trails/webhook-route-collision.trail.ts +50 -0
- package/src/trails/wrap-rule.ts +214 -0
- package/src/workspaces.ts +238 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/cli.d.ts +0 -46
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -221
- package/dist/cli.js.map +0 -1
- package/dist/drift.d.ts +0 -26
- package/dist/drift.d.ts.map +0 -1
- package/dist/drift.js +0 -27
- package/dist/drift.js.map +0 -1
- package/dist/formatters.d.ts +0 -29
- package/dist/formatters.d.ts.map +0 -1
- package/dist/formatters.js +0 -87
- package/dist/formatters.js.map +0 -1
- package/dist/index.d.ts +0 -26
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -26
- package/dist/index.js.map +0 -1
- package/dist/rules/ast.d.ts +0 -41
- package/dist/rules/ast.d.ts.map +0 -1
- package/dist/rules/ast.js +0 -163
- package/dist/rules/ast.js.map +0 -1
- package/dist/rules/context-no-surface-types.d.ts +0 -12
- package/dist/rules/context-no-surface-types.d.ts.map +0 -1
- package/dist/rules/context-no-surface-types.js +0 -96
- package/dist/rules/context-no-surface-types.js.map +0 -1
- package/dist/rules/implementation-returns-result.d.ts +0 -13
- package/dist/rules/implementation-returns-result.d.ts.map +0 -1
- package/dist/rules/implementation-returns-result.js +0 -277
- package/dist/rules/implementation-returns-result.js.map +0 -1
- package/dist/rules/index.d.ts +0 -22
- package/dist/rules/index.d.ts.map +0 -1
- package/dist/rules/index.js +0 -41
- package/dist/rules/index.js.map +0 -1
- package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
- package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
- package/dist/rules/no-direct-impl-in-route.js +0 -46
- package/dist/rules/no-direct-impl-in-route.js.map +0 -1
- package/dist/rules/no-direct-implementation-call.d.ts +0 -12
- package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
- package/dist/rules/no-direct-implementation-call.js +0 -39
- package/dist/rules/no-direct-implementation-call.js.map +0 -1
- package/dist/rules/no-sync-result-assumption.d.ts +0 -6
- package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
- package/dist/rules/no-sync-result-assumption.js +0 -98
- package/dist/rules/no-sync-result-assumption.js.map +0 -1
- package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
- package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
- package/dist/rules/no-throw-in-detour-target.js +0 -87
- package/dist/rules/no-throw-in-detour-target.js.map +0 -1
- package/dist/rules/no-throw-in-implementation.d.ts +0 -9
- package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
- package/dist/rules/no-throw-in-implementation.js +0 -34
- package/dist/rules/no-throw-in-implementation.js.map +0 -1
- package/dist/rules/prefer-schema-inference.d.ts +0 -7
- package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
- package/dist/rules/prefer-schema-inference.js +0 -86
- package/dist/rules/prefer-schema-inference.js.map +0 -1
- package/dist/rules/scan.d.ts +0 -8
- package/dist/rules/scan.d.ts.map +0 -1
- package/dist/rules/scan.js +0 -32
- package/dist/rules/scan.js.map +0 -1
- package/dist/rules/specs.d.ts +0 -29
- package/dist/rules/specs.d.ts.map +0 -1
- package/dist/rules/specs.js +0 -192
- package/dist/rules/specs.js.map +0 -1
- package/dist/rules/structure.d.ts +0 -13
- package/dist/rules/structure.d.ts.map +0 -1
- package/dist/rules/structure.js +0 -142
- package/dist/rules/structure.js.map +0 -1
- package/dist/rules/types.d.ts +0 -52
- package/dist/rules/types.d.ts.map +0 -1
- package/dist/rules/types.js +0 -2
- package/dist/rules/types.js.map +0 -1
- package/dist/rules/valid-describe-refs.d.ts +0 -7
- package/dist/rules/valid-describe-refs.d.ts.map +0 -1
- package/dist/rules/valid-describe-refs.js +0 -51
- package/dist/rules/valid-describe-refs.js.map +0 -1
- package/dist/rules/valid-detour-refs.d.ts +0 -6
- package/dist/rules/valid-detour-refs.d.ts.map +0 -1
- package/dist/rules/valid-detour-refs.js +0 -116
- package/dist/rules/valid-detour-refs.js.map +0 -1
- package/src/__tests__/cli.test.ts +0 -198
- package/src/__tests__/drift.test.ts +0 -74
- package/src/__tests__/formatters.test.ts +0 -157
- package/src/__tests__/implementation-returns-result.test.ts +0 -128
- package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
- package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
- package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
- package/src/__tests__/prefer-schema-inference.test.ts +0 -84
- package/src/__tests__/rules.test.ts +0 -215
- package/src/__tests__/valid-describe-refs.test.ts +0 -60
- package/src/rules/no-direct-impl-in-route.ts +0 -77
- package/src/rules/no-throw-in-detour-target.ts +0 -150
- package/src/rules/valid-detour-refs.ts +0 -187
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that registered surface error mappers cover every error category.
|
|
3
|
+
*
|
|
4
|
+
* Scans `createSurfaceErrorMapper(...)` calls, then resolves simple object
|
|
5
|
+
* literals, identifier bindings, and object-property references in the same
|
|
6
|
+
* file so incomplete mapper registrations are caught before they ship.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { codesByCategory, errorClasses } from '@ontrails/core';
|
|
10
|
+
import type { ErrorCategory } from '@ontrails/core';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
getNodeKey,
|
|
14
|
+
getNodeValueNode,
|
|
15
|
+
getStringValue,
|
|
16
|
+
identifierName,
|
|
17
|
+
isStringLiteral,
|
|
18
|
+
offsetToLine,
|
|
19
|
+
parse,
|
|
20
|
+
walk,
|
|
21
|
+
} from './ast.js';
|
|
22
|
+
import type { AstNode } from './ast.js';
|
|
23
|
+
import { isTestFile } from './scan.js';
|
|
24
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
25
|
+
|
|
26
|
+
const MEMBER_EXPRESSION_TYPES = new Set([
|
|
27
|
+
'MemberExpression',
|
|
28
|
+
'StaticMemberExpression',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const MAPPER_FACTORY_NAMES = new Set(['createSurfaceErrorMapper']);
|
|
32
|
+
|
|
33
|
+
const mappedErrorClassCategories = new Set(
|
|
34
|
+
errorClasses.flatMap((entry) =>
|
|
35
|
+
entry.category === 'dynamic' ? [] : [entry.category]
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const requiredErrorCategories = (
|
|
40
|
+
Object.keys(codesByCategory) as ErrorCategory[]
|
|
41
|
+
).filter((category) => mappedErrorClassCategories.has(category));
|
|
42
|
+
|
|
43
|
+
const getPropertyName = (node: AstNode | undefined): string | null => {
|
|
44
|
+
if (!node) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
identifierName(node) ??
|
|
50
|
+
(isStringLiteral(node) ? getStringValue(node) : null)
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const collectObjectBindings = (ast: AstNode): ReadonlyMap<string, AstNode> => {
|
|
55
|
+
const bindings = new Map<string, AstNode>();
|
|
56
|
+
|
|
57
|
+
walk(ast, (node) => {
|
|
58
|
+
if (node.type !== 'VariableDeclarator') {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { id, init } = node as { id?: AstNode; init?: AstNode };
|
|
63
|
+
const bindingName = identifierName(id);
|
|
64
|
+
|
|
65
|
+
if (bindingName && init?.type === 'ObjectExpression') {
|
|
66
|
+
bindings.set(bindingName, init);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return bindings;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const getObjectProperties = (objectNode: AstNode): readonly AstNode[] =>
|
|
74
|
+
objectNode.type === 'ObjectExpression'
|
|
75
|
+
? ((objectNode['properties'] as readonly AstNode[] | undefined) ?? [])
|
|
76
|
+
: [];
|
|
77
|
+
|
|
78
|
+
const findObjectPropertyValue = (
|
|
79
|
+
objectNode: AstNode,
|
|
80
|
+
propertyName: string
|
|
81
|
+
): AstNode | null => {
|
|
82
|
+
for (const property of getObjectProperties(objectNode)) {
|
|
83
|
+
if (property.type !== 'Property') {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const key = getPropertyName(getNodeKey(property));
|
|
88
|
+
if (key === propertyName) {
|
|
89
|
+
return getNodeValueNode(property) ?? null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const resolveIdentifierObject = (
|
|
97
|
+
node: AstNode,
|
|
98
|
+
bindings: ReadonlyMap<string, AstNode>
|
|
99
|
+
): AstNode | null =>
|
|
100
|
+
bindings.get((node as { name?: string }).name ?? '') ?? null;
|
|
101
|
+
|
|
102
|
+
const resolveMemberObject = (
|
|
103
|
+
node: AstNode,
|
|
104
|
+
bindings: ReadonlyMap<string, AstNode>,
|
|
105
|
+
depth: number,
|
|
106
|
+
resolve: (
|
|
107
|
+
node: AstNode | undefined,
|
|
108
|
+
bindings: ReadonlyMap<string, AstNode>,
|
|
109
|
+
depth?: number
|
|
110
|
+
) => AstNode | null
|
|
111
|
+
): AstNode | null => {
|
|
112
|
+
const { object, property } = node as { object?: AstNode; property?: AstNode };
|
|
113
|
+
const propertyName = getPropertyName(property);
|
|
114
|
+
if (!propertyName) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const objectNode = resolve(object, bindings, depth + 1);
|
|
119
|
+
return objectNode
|
|
120
|
+
? resolve(
|
|
121
|
+
findObjectPropertyValue(objectNode, propertyName) ?? undefined,
|
|
122
|
+
bindings,
|
|
123
|
+
depth + 1
|
|
124
|
+
)
|
|
125
|
+
: null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const resolveObjectExpression = function resolveObjectExpression(
|
|
129
|
+
node: AstNode | undefined,
|
|
130
|
+
bindings: ReadonlyMap<string, AstNode>,
|
|
131
|
+
depth = 0
|
|
132
|
+
): AstNode | null {
|
|
133
|
+
if (!node || depth > 4) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (node.type === 'ObjectExpression') {
|
|
138
|
+
return node;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (node.type === 'Identifier') {
|
|
142
|
+
return resolveIdentifierObject(node, bindings);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return MEMBER_EXPRESSION_TYPES.has(node.type)
|
|
146
|
+
? resolveMemberObject(node, bindings, depth, resolveObjectExpression)
|
|
147
|
+
: null;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const addMappedCategory = (
|
|
151
|
+
categories: Set<string>,
|
|
152
|
+
property: AstNode
|
|
153
|
+
): boolean => {
|
|
154
|
+
if (property.type === 'SpreadElement') {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (property.type !== 'Property') {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const key = getPropertyName(getNodeKey(property));
|
|
163
|
+
if (key) {
|
|
164
|
+
categories.add(key);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return true;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const collectMappedCategories = (
|
|
171
|
+
mapperObject: AstNode
|
|
172
|
+
): ReadonlySet<string> | null => {
|
|
173
|
+
if (mapperObject.type !== 'ObjectExpression') {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const categories = new Set<string>();
|
|
178
|
+
for (const property of getObjectProperties(mapperObject)) {
|
|
179
|
+
if (!addMappedCategory(categories, property)) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return categories;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const createDiagnostic = (
|
|
188
|
+
filePath: string,
|
|
189
|
+
line: number,
|
|
190
|
+
missingCategories: readonly string[]
|
|
191
|
+
): WardenDiagnostic => ({
|
|
192
|
+
filePath,
|
|
193
|
+
line,
|
|
194
|
+
message: `Surface error mapper is missing mappings for: ${missingCategories.join(', ')}. Registered createSurfaceErrorMapper() calls must cover every ErrorCategory.`,
|
|
195
|
+
rule: 'error-mapping-completeness',
|
|
196
|
+
severity: 'error',
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const getCallArgs = (node: AstNode): readonly AstNode[] =>
|
|
200
|
+
(node as { arguments?: readonly AstNode[] }).arguments ?? [];
|
|
201
|
+
|
|
202
|
+
const getCallCallee = (node: AstNode): AstNode | undefined =>
|
|
203
|
+
(node as { callee?: AstNode }).callee;
|
|
204
|
+
|
|
205
|
+
const isMapperFactoryCall = (node: AstNode): boolean =>
|
|
206
|
+
node.type === 'CallExpression' &&
|
|
207
|
+
MAPPER_FACTORY_NAMES.has(identifierName(getCallCallee(node)) ?? '');
|
|
208
|
+
|
|
209
|
+
const findMissingCategories = (
|
|
210
|
+
mappedCategories: ReadonlySet<string>
|
|
211
|
+
): readonly string[] =>
|
|
212
|
+
requiredErrorCategories.filter((category) => !mappedCategories.has(category));
|
|
213
|
+
|
|
214
|
+
const resolveMappedCategories = (
|
|
215
|
+
node: AstNode,
|
|
216
|
+
bindings: ReadonlyMap<string, AstNode>
|
|
217
|
+
): ReadonlySet<string> | null => {
|
|
218
|
+
const [firstArg] = getCallArgs(node);
|
|
219
|
+
const mapperObject = resolveObjectExpression(firstArg, bindings);
|
|
220
|
+
return mapperObject ? collectMappedCategories(mapperObject) : null;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const inspectMapperCall = (
|
|
224
|
+
node: AstNode,
|
|
225
|
+
bindings: ReadonlyMap<string, AstNode>,
|
|
226
|
+
filePath: string,
|
|
227
|
+
sourceCode: string
|
|
228
|
+
): WardenDiagnostic | null => {
|
|
229
|
+
if (!isMapperFactoryCall(node)) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const mappedCategories = resolveMappedCategories(node, bindings);
|
|
234
|
+
if (!mappedCategories) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const missingCategories = findMissingCategories(mappedCategories);
|
|
239
|
+
if (missingCategories.length === 0) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return createDiagnostic(
|
|
244
|
+
filePath,
|
|
245
|
+
offsetToLine(sourceCode, node.start),
|
|
246
|
+
missingCategories
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Flags registered surface error mapper calls that omit error categories.
|
|
252
|
+
*/
|
|
253
|
+
export const errorMappingCompleteness: WardenRule = {
|
|
254
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
255
|
+
if (
|
|
256
|
+
isTestFile(filePath) ||
|
|
257
|
+
![...MAPPER_FACTORY_NAMES].some((factoryName) =>
|
|
258
|
+
sourceCode.includes(factoryName)
|
|
259
|
+
)
|
|
260
|
+
) {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const ast = parse(filePath, sourceCode);
|
|
265
|
+
if (!ast) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const bindings = collectObjectBindings(ast);
|
|
270
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
271
|
+
|
|
272
|
+
walk(ast, (node) => {
|
|
273
|
+
const diagnostic = inspectMapperCall(
|
|
274
|
+
node,
|
|
275
|
+
bindings,
|
|
276
|
+
filePath,
|
|
277
|
+
sourceCode
|
|
278
|
+
);
|
|
279
|
+
if (diagnostic) {
|
|
280
|
+
diagnostics.push(diagnostic);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return diagnostics;
|
|
285
|
+
},
|
|
286
|
+
description:
|
|
287
|
+
'Require registered surface error mappers to cover every ErrorCategory.',
|
|
288
|
+
name: 'error-mapping-completeness',
|
|
289
|
+
severity: 'error',
|
|
290
|
+
};
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { contour } from '@ontrails/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
extractStringLiteral,
|
|
6
|
+
findConfigProperty,
|
|
7
|
+
findContourDefinitions,
|
|
8
|
+
getNodeExpression,
|
|
9
|
+
getNodeObject,
|
|
10
|
+
getNodeProperty,
|
|
11
|
+
getStringValue,
|
|
12
|
+
identifierName,
|
|
13
|
+
offsetToLine,
|
|
14
|
+
parse,
|
|
15
|
+
} from './ast.js';
|
|
16
|
+
import type { AstNode, ContourDefinition } from './ast.js';
|
|
17
|
+
import { isTestFile } from './scan.js';
|
|
18
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
19
|
+
|
|
20
|
+
class UnsupportedContourEvaluationError extends Error {}
|
|
21
|
+
|
|
22
|
+
type ContourEvaluationEnvironment = ReadonlyMap<string, unknown>;
|
|
23
|
+
|
|
24
|
+
const buildInvalidExampleDiagnostic = (
|
|
25
|
+
contourName: string,
|
|
26
|
+
message: string,
|
|
27
|
+
filePath: string,
|
|
28
|
+
line: number
|
|
29
|
+
): WardenDiagnostic => ({
|
|
30
|
+
filePath,
|
|
31
|
+
line,
|
|
32
|
+
message: `Contour "${contourName}" has invalid examples: ${message}`,
|
|
33
|
+
rule: 'example-valid',
|
|
34
|
+
severity: 'error',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const getPropertyName = (node: unknown): string | null => {
|
|
38
|
+
if (typeof node !== 'object' || node === null) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { name } = node as { readonly name?: unknown };
|
|
43
|
+
if (typeof name === 'string') {
|
|
44
|
+
return name;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return extractStringLiteral(node as AstNode);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const requireNode = (node: AstNode | undefined): AstNode => {
|
|
51
|
+
if (!node) {
|
|
52
|
+
throw new UnsupportedContourEvaluationError(
|
|
53
|
+
'Missing node in contour evaluation.'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return node;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type ContourNodeEvaluator = (
|
|
61
|
+
node: AstNode,
|
|
62
|
+
env: ContourEvaluationEnvironment
|
|
63
|
+
) => unknown;
|
|
64
|
+
|
|
65
|
+
// The evaluator table is declared before `evaluateNode` so the recursive
|
|
66
|
+
// dispatch can read it at call time without any forward references. Entries
|
|
67
|
+
// are registered below once each per-type evaluator is defined.
|
|
68
|
+
const contourNodeEvaluators = new Map<string, ContourNodeEvaluator>();
|
|
69
|
+
|
|
70
|
+
const evaluateNode: ContourNodeEvaluator = (
|
|
71
|
+
node: AstNode,
|
|
72
|
+
env: ContourEvaluationEnvironment
|
|
73
|
+
): unknown => {
|
|
74
|
+
const evaluator = contourNodeEvaluators.get(node.type);
|
|
75
|
+
if (evaluator) {
|
|
76
|
+
return evaluator(node, env);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new UnsupportedContourEvaluationError(
|
|
80
|
+
`Unsupported AST node "${node.type}" in contour evaluation.`
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const evaluateArrayExpression: ContourNodeEvaluator = (
|
|
85
|
+
node: AstNode,
|
|
86
|
+
env: ContourEvaluationEnvironment
|
|
87
|
+
): readonly unknown[] => {
|
|
88
|
+
const elements = node['elements'] as readonly AstNode[] | undefined;
|
|
89
|
+
return (elements ?? []).map((element) => evaluateNode(element, env));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const evaluateLiteralExpression: ContourNodeEvaluator = (
|
|
93
|
+
node: AstNode
|
|
94
|
+
): unknown => getStringValue(node) ?? node.value;
|
|
95
|
+
|
|
96
|
+
const evaluateIdentifierExpression: ContourNodeEvaluator = (
|
|
97
|
+
node: AstNode,
|
|
98
|
+
env: ContourEvaluationEnvironment
|
|
99
|
+
): unknown => {
|
|
100
|
+
const name = identifierName(node);
|
|
101
|
+
if (name === 'undefined') {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (name === 'z') {
|
|
106
|
+
return z;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!name || !env.has(name)) {
|
|
110
|
+
throw new UnsupportedContourEvaluationError(
|
|
111
|
+
`Unknown identifier "${name ?? '<unknown>'}" in contour evaluation.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return env.get(name);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const evaluateWrappedExpression: ContourNodeEvaluator = (
|
|
119
|
+
node: AstNode,
|
|
120
|
+
env: ContourEvaluationEnvironment
|
|
121
|
+
): unknown => evaluateNode(requireNode(getNodeExpression(node)), env);
|
|
122
|
+
|
|
123
|
+
const evaluateNullExpression: ContourNodeEvaluator = (): null => null;
|
|
124
|
+
|
|
125
|
+
const evaluateObjectExpression: ContourNodeEvaluator = (
|
|
126
|
+
node: AstNode,
|
|
127
|
+
env: ContourEvaluationEnvironment
|
|
128
|
+
): Record<string, unknown> => {
|
|
129
|
+
const properties = node['properties'] as readonly AstNode[] | undefined;
|
|
130
|
+
const value: Record<string, unknown> = {};
|
|
131
|
+
|
|
132
|
+
for (const property of properties ?? []) {
|
|
133
|
+
if (property.type !== 'Property') {
|
|
134
|
+
throw new UnsupportedContourEvaluationError(
|
|
135
|
+
`Unsupported object property type "${property.type}".`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const propertyName = getPropertyName(property.key);
|
|
140
|
+
if (!propertyName) {
|
|
141
|
+
throw new UnsupportedContourEvaluationError(
|
|
142
|
+
'Unsupported object property key in contour evaluation.'
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
value[propertyName] = evaluateNode(
|
|
147
|
+
requireNode(property.value as AstNode | undefined),
|
|
148
|
+
env
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return value;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const evaluateCallArguments = (
|
|
156
|
+
node: AstNode,
|
|
157
|
+
env: ContourEvaluationEnvironment
|
|
158
|
+
): readonly unknown[] =>
|
|
159
|
+
((node['arguments'] as readonly AstNode[] | undefined) ?? []).map((arg) =>
|
|
160
|
+
evaluateNode(arg, env)
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const resolveContourFallbackOptions = (
|
|
164
|
+
shape: unknown
|
|
165
|
+
): Parameters<typeof contour>[2] | null =>
|
|
166
|
+
typeof shape === 'object' &&
|
|
167
|
+
shape !== null &&
|
|
168
|
+
Object.hasOwn(shape, 'id') &&
|
|
169
|
+
(shape as z.ZodRawShape)['id'] !== undefined
|
|
170
|
+
? ({ identity: 'id' } as Parameters<typeof contour>[2])
|
|
171
|
+
: null;
|
|
172
|
+
|
|
173
|
+
const resolveContourOptions = (
|
|
174
|
+
shape: unknown,
|
|
175
|
+
options: unknown
|
|
176
|
+
): Parameters<typeof contour>[2] => {
|
|
177
|
+
if (options === undefined) {
|
|
178
|
+
const fallbackOptions = resolveContourFallbackOptions(shape);
|
|
179
|
+
if (fallbackOptions !== null) {
|
|
180
|
+
return fallbackOptions;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw new UnsupportedContourEvaluationError(
|
|
184
|
+
'Contour evaluator requires literal options, or an `id` field when options are omitted.'
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (typeof options !== 'object' || options === null) {
|
|
189
|
+
throw new UnsupportedContourEvaluationError(
|
|
190
|
+
'Contour evaluator requires literal options when provided.'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return options as Parameters<typeof contour>[2];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const evaluateContourCall = (args: readonly unknown[]): unknown => {
|
|
198
|
+
const [name, shape, options] = args;
|
|
199
|
+
if (typeof name !== 'string') {
|
|
200
|
+
throw new UnsupportedContourEvaluationError(
|
|
201
|
+
'Contour evaluator requires a literal name.'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return contour(
|
|
206
|
+
name,
|
|
207
|
+
shape as z.ZodRawShape,
|
|
208
|
+
resolveContourOptions(shape, options)
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const evaluateMemberCall = (
|
|
213
|
+
callee: AstNode,
|
|
214
|
+
args: readonly unknown[],
|
|
215
|
+
env: ContourEvaluationEnvironment
|
|
216
|
+
): unknown => {
|
|
217
|
+
const receiver = evaluateNode(requireNode(getNodeObject(callee)), env);
|
|
218
|
+
const propertyName = getPropertyName(getNodeProperty(callee));
|
|
219
|
+
if (!propertyName) {
|
|
220
|
+
throw new UnsupportedContourEvaluationError(
|
|
221
|
+
'Unsupported member property in contour evaluation.'
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const method = (receiver as Record<string, unknown>)[propertyName];
|
|
226
|
+
if (typeof method !== 'function') {
|
|
227
|
+
throw new UnsupportedContourEvaluationError(
|
|
228
|
+
`Contour evaluator could not call "${propertyName}".`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return Reflect.apply(method, receiver, args);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const evaluateCallExpression: ContourNodeEvaluator = (
|
|
236
|
+
node: AstNode,
|
|
237
|
+
env: ContourEvaluationEnvironment
|
|
238
|
+
): unknown => {
|
|
239
|
+
const args = evaluateCallArguments(node, env);
|
|
240
|
+
const callee = requireNode(node['callee'] as AstNode | undefined);
|
|
241
|
+
|
|
242
|
+
if (callee.type === 'Identifier') {
|
|
243
|
+
const calleeName = identifierName(callee);
|
|
244
|
+
if (calleeName !== 'contour') {
|
|
245
|
+
throw new UnsupportedContourEvaluationError(
|
|
246
|
+
`Unsupported contour evaluator call "${calleeName ?? '<unknown>'}".`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return evaluateContourCall(args);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (
|
|
254
|
+
callee.type !== 'MemberExpression' &&
|
|
255
|
+
callee.type !== 'StaticMemberExpression'
|
|
256
|
+
) {
|
|
257
|
+
throw new UnsupportedContourEvaluationError(
|
|
258
|
+
`Unsupported callee type "${callee.type}".`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return evaluateMemberCall(callee, args, env);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const EVALUATOR_REGISTRATIONS: readonly (readonly [
|
|
266
|
+
string,
|
|
267
|
+
ContourNodeEvaluator,
|
|
268
|
+
])[] = [
|
|
269
|
+
['ArrayExpression', evaluateArrayExpression],
|
|
270
|
+
['BooleanLiteral', evaluateLiteralExpression],
|
|
271
|
+
['Literal', evaluateLiteralExpression],
|
|
272
|
+
['NumericLiteral', evaluateLiteralExpression],
|
|
273
|
+
['StringLiteral', evaluateLiteralExpression],
|
|
274
|
+
['CallExpression', evaluateCallExpression],
|
|
275
|
+
['Identifier', evaluateIdentifierExpression],
|
|
276
|
+
['NullLiteral', evaluateNullExpression],
|
|
277
|
+
['ObjectExpression', evaluateObjectExpression],
|
|
278
|
+
['ParenthesizedExpression', evaluateWrappedExpression],
|
|
279
|
+
['TSAsExpression', evaluateWrappedExpression],
|
|
280
|
+
['TSSatisfiesExpression', evaluateWrappedExpression],
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
for (const [type, evaluator] of EVALUATOR_REGISTRATIONS) {
|
|
284
|
+
contourNodeEvaluators.set(type, evaluator);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const hasExamples = (definition: ContourDefinition): boolean =>
|
|
288
|
+
definition.options !== null &&
|
|
289
|
+
findConfigProperty(definition.options, 'examples') !== null;
|
|
290
|
+
|
|
291
|
+
const evaluateContourDefinition = (
|
|
292
|
+
definition: ContourDefinition,
|
|
293
|
+
env: ContourEvaluationEnvironment
|
|
294
|
+
): unknown => evaluateNode(definition.call, env);
|
|
295
|
+
|
|
296
|
+
const buildContourExampleErrorDiagnostic = (
|
|
297
|
+
definition: ContourDefinition,
|
|
298
|
+
error: unknown,
|
|
299
|
+
sourceCode: string,
|
|
300
|
+
filePath: string
|
|
301
|
+
): WardenDiagnostic | null => {
|
|
302
|
+
if (
|
|
303
|
+
error instanceof UnsupportedContourEvaluationError ||
|
|
304
|
+
!hasExamples(definition) ||
|
|
305
|
+
!(error instanceof Error)
|
|
306
|
+
) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return buildInvalidExampleDiagnostic(
|
|
311
|
+
definition.name,
|
|
312
|
+
error.message,
|
|
313
|
+
filePath,
|
|
314
|
+
offsetToLine(sourceCode, definition.start)
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const evaluateContourExamples = (
|
|
319
|
+
definition: ContourDefinition,
|
|
320
|
+
env: Map<string, unknown>,
|
|
321
|
+
sourceCode: string,
|
|
322
|
+
filePath: string
|
|
323
|
+
): WardenDiagnostic | null => {
|
|
324
|
+
try {
|
|
325
|
+
const value = evaluateContourDefinition(definition, env);
|
|
326
|
+
if (definition.bindingName) {
|
|
327
|
+
env.set(definition.bindingName, value);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return null;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
return buildContourExampleErrorDiagnostic(
|
|
333
|
+
definition,
|
|
334
|
+
error,
|
|
335
|
+
sourceCode,
|
|
336
|
+
filePath
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const collectContourExampleDiagnostics = (
|
|
342
|
+
definitions: readonly ContourDefinition[],
|
|
343
|
+
sourceCode: string,
|
|
344
|
+
filePath: string
|
|
345
|
+
): readonly WardenDiagnostic[] => {
|
|
346
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
347
|
+
const env = new Map<string, unknown>();
|
|
348
|
+
|
|
349
|
+
for (const definition of definitions) {
|
|
350
|
+
const diagnostic = evaluateContourExamples(
|
|
351
|
+
definition,
|
|
352
|
+
env,
|
|
353
|
+
sourceCode,
|
|
354
|
+
filePath
|
|
355
|
+
);
|
|
356
|
+
if (diagnostic) {
|
|
357
|
+
diagnostics.push(diagnostic);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return diagnostics;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const checkContourExamples = (
|
|
365
|
+
sourceCode: string,
|
|
366
|
+
filePath: string
|
|
367
|
+
): readonly WardenDiagnostic[] => {
|
|
368
|
+
if (isTestFile(filePath)) {
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const ast = parse(filePath, sourceCode);
|
|
373
|
+
if (!ast) {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return collectContourExampleDiagnostics(
|
|
378
|
+
findContourDefinitions(ast),
|
|
379
|
+
sourceCode,
|
|
380
|
+
filePath
|
|
381
|
+
);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Checks that contour examples validate against their schema.
|
|
386
|
+
*/
|
|
387
|
+
export const exampleValid: WardenRule = {
|
|
388
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
389
|
+
return checkContourExamples(sourceCode, filePath);
|
|
390
|
+
},
|
|
391
|
+
description:
|
|
392
|
+
'Ensure every contour example validates against the declared contour schema.',
|
|
393
|
+
name: 'example-valid',
|
|
394
|
+
severity: 'error',
|
|
395
|
+
};
|