@ontrails/warden 1.0.0-beta.39 → 1.0.0-beta.41
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 +125 -0
- package/README.md +5 -5
- package/package.json +10 -12
- package/src/cli.ts +49 -44
- package/src/command.ts +2 -2
- package/src/drift.ts +4 -3
- package/src/index.ts +3 -2
- package/src/project-context.ts +3 -3
- package/src/resolve.ts +2 -2
- package/src/rules/captured-kernel.ts +375 -0
- package/src/rules/circular-refs.ts +12 -16
- package/src/rules/cli-command-route-coherence.ts +1 -1
- package/src/rules/composes-declarations.ts +15 -13
- package/src/rules/context-no-surface-types.ts +4 -3
- package/src/rules/dead-internal-trail.ts +3 -3
- package/src/rules/dead-public-trail.ts +3 -3
- package/src/rules/draft-file-marking.ts +4 -9
- package/src/rules/draft-visible-debt.ts +3 -8
- package/src/rules/duplicate-public-contract.ts +50 -5
- package/src/rules/{contour-exists.ts → entity-exists.ts} +67 -64
- package/src/rules/entity-ids.ts +15 -0
- package/src/rules/error-mapping-completeness.ts +2 -2
- package/src/rules/example-valid.ts +84 -84
- package/src/rules/fires-declarations.ts +27 -22
- package/src/rules/governed-symbol-residue.ts +339 -32
- package/src/rules/implementation-returns-result.ts +8 -8
- package/src/rules/incomplete-accessor-for-standard-op.ts +15 -15
- package/src/rules/incomplete-crud.ts +42 -40
- package/src/rules/index.ts +9 -6
- package/src/rules/intent-propagation.ts +3 -5
- package/src/rules/layer-field-name-drift.ts +2 -2
- package/src/rules/library-projection-coherence.ts +2 -2
- package/src/rules/metadata.ts +37 -15
- package/src/rules/missing-reconcile.ts +3 -4
- package/src/rules/missing-visibility.ts +2 -1
- package/src/rules/no-destructured-compose.ts +14 -12
- package/src/rules/no-direct-implementation-call.ts +12 -7
- package/src/rules/no-legacy-cli-alias-export.ts +2 -2
- package/src/rules/no-native-error-result.ts +2 -2
- package/src/rules/no-redundant-result-error-wrap.ts +10 -10
- package/src/rules/no-sync-result-assumption.ts +84 -78
- package/src/rules/no-throw-in-detour-recover.ts +2 -2
- package/src/rules/no-throw-in-implementation.ts +13 -7
- package/src/rules/no-top-level-surface.ts +2 -2
- package/src/rules/on-references-exist.ts +3 -3
- package/src/rules/orphaned-signal.ts +4 -5
- package/src/rules/owner-projection-parity.ts +2 -2
- package/src/rules/public-export-example-coverage.ts +10 -4
- package/src/rules/public-internal-deep-imports.ts +2 -3
- package/src/rules/read-intent-fires.ts +4 -3
- package/src/rules/reference-exists.ts +20 -21
- package/src/rules/registry-names.ts +6 -4
- package/src/rules/resolved-import-boundary.ts +1 -1
- package/src/rules/resource-declarations.ts +21 -17
- package/src/rules/resource-exists.ts +4 -2
- package/src/rules/resource-id-grammar.ts +2 -2
- package/src/rules/resource-mock-coverage.ts +2 -2
- package/src/rules/retired-vocabulary.ts +242 -5
- package/src/rules/source/composition.ts +165 -0
- package/src/rules/source/drafts.ts +164 -0
- package/src/rules/source/entities.ts +618 -0
- package/src/rules/source/pragmas.ts +45 -0
- package/src/rules/source/resources.ts +64 -0
- package/src/rules/source/signals.ts +397 -0
- package/src/rules/source/stores.ts +310 -0
- package/src/rules/static-resource-accessor-preference.ts +15 -11
- package/src/rules/surface-overlay-coherence.ts +1 -1
- package/src/rules/surface-trailhead-coherence.ts +2 -2
- package/src/rules/trail-fork-coaching.ts +27 -18
- package/src/rules/trail-versioning-source.ts +16 -12
- package/src/rules/trail-versioning-topo.ts +2 -2
- package/src/rules/trailhead-override-divergence.ts +2 -2
- package/src/rules/types.ts +7 -7
- package/src/rules/unmaterialized-activation-source.ts +1 -0
- package/src/rules/unreachable-detour-shadowing.ts +2 -2
- package/src/rules/valid-describe-refs.ts +2 -2
- package/src/rules/warden-export-symmetry.ts +3 -3
- package/src/rules/warden-rules-use-ast.ts +12 -17
- package/src/trails/activation-orphan.trail.ts +5 -5
- package/src/trails/captured-kernel.trail.ts +108 -0
- package/src/trails/circular-refs.trail.ts +6 -6
- package/src/trails/cli-command-route-coherence.trail.ts +2 -2
- package/src/trails/composes-declarations.trail.ts +1 -1
- package/src/trails/context-no-surface-types.trail.ts +1 -1
- package/src/trails/dead-internal-trail.trail.ts +2 -2
- package/src/trails/dead-public-trail.trail.ts +1 -1
- package/src/trails/deprecation-without-guidance.trail.ts +1 -1
- package/src/trails/duplicate-public-contract.trail.ts +3 -3
- package/src/trails/entity-exists.trail.ts +21 -0
- package/src/trails/example-valid.trail.ts +3 -3
- package/src/trails/fires-declarations.trail.ts +1 -1
- package/src/trails/{fork-without-preserved-blaze.trail.ts → fork-without-preserved-implementation.trail.ts} +4 -4
- package/src/trails/implementation-returns-result.trail.ts +1 -1
- package/src/trails/incomplete-accessor-for-standard-op.trail.ts +4 -4
- package/src/trails/index.ts +3 -2
- package/src/trails/intent-propagation.trail.ts +2 -2
- package/src/trails/library-projection-coherence.trail.ts +2 -2
- package/src/trails/marker-schema-unsupported.trail.ts +1 -1
- package/src/trails/missing-visibility.trail.ts +1 -1
- package/src/trails/no-destructured-compose.trail.ts +5 -5
- package/src/trails/no-direct-implementation-call.trail.ts +1 -1
- package/src/trails/no-redundant-result-error-wrap.trail.ts +2 -2
- package/src/trails/no-sync-result-assumption.trail.ts +2 -2
- package/src/trails/no-throw-in-detour-recover.trail.ts +1 -1
- package/src/trails/no-throw-in-implementation.trail.ts +1 -1
- package/src/trails/on-references-exist.trail.ts +1 -1
- package/src/trails/pending-force.trail.ts +1 -1
- package/src/trails/permit-governance.trail.ts +2 -2
- package/src/trails/prefer-schema-inference.trail.ts +1 -1
- package/src/trails/public-output-schema.trail.ts +2 -2
- package/src/trails/public-union-output-discriminants.trail.ts +1 -1
- package/src/trails/read-intent-fires.trail.ts +1 -1
- package/src/trails/reference-exists.trail.ts +5 -5
- package/src/trails/resource-declarations.trail.ts +1 -1
- package/src/trails/resource-exists.trail.ts +1 -1
- package/src/trails/run.ts +4 -6
- package/src/trails/scheduled-destroy-intent.trail.ts +2 -2
- package/src/trails/schema.ts +9 -9
- package/src/trails/signal-graph-coaching.trail.ts +2 -2
- package/src/trails/static-resource-accessor-preference.trail.ts +1 -1
- package/src/trails/surface-overlay-coherence.trail.ts +1 -1
- package/src/trails/trail-fork-coaching.trail.ts +1 -1
- package/src/trails/unmaterialized-activation-source.trail.ts +5 -5
- package/src/trails/valid-detour-contract.trail.ts +1 -1
- package/src/trails/version-gap.trail.ts +1 -1
- package/src/trails/version-pinned-compose.trail.ts +1 -1
- package/src/trails/version-without-examples.trail.ts +1 -1
- package/src/trails/warden-rules-use-ast.trail.ts +2 -2
- package/src/trails/webhook-route-collision.trail.ts +2 -2
- package/src/trails/wrap-rule.ts +17 -17
- package/src/ast.ts +0 -137
- package/src/rules/ast.ts +0 -4143
- package/src/rules/contour-ids.ts +0 -15
- package/src/trails/contour-exists.trail.ts +0 -21
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
+
getNodeKey,
|
|
3
|
+
getNodeValueNode,
|
|
2
4
|
identifierName,
|
|
3
5
|
offsetToLine,
|
|
4
6
|
parseWithDiagnostics,
|
|
5
|
-
|
|
6
|
-
} from '
|
|
7
|
-
import type { AstNode } from '
|
|
7
|
+
walkWithScopeContext,
|
|
8
|
+
} from '@ontrails/source';
|
|
9
|
+
import type { AstNode, AstScopeContext } from '@ontrails/source';
|
|
8
10
|
import { listGovernedVocabularyTransitions } from './retired-vocabulary.js';
|
|
9
11
|
import type {
|
|
10
12
|
GovernedVocabularySymbolRename,
|
|
@@ -34,42 +36,319 @@ const isAllowedSourcePath = (filePath: string): boolean =>
|
|
|
34
36
|
normalizePath(filePath).endsWith(suffix)
|
|
35
37
|
);
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
interface ActiveSymbolRename {
|
|
40
|
+
readonly rename: GovernedVocabularySymbolRename;
|
|
41
|
+
readonly targetSegments: readonly string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const activeSymbolRenames = (): readonly ActiveSymbolRename[] =>
|
|
38
45
|
listGovernedVocabularyTransitions()
|
|
39
46
|
.filter(
|
|
40
47
|
(transition) =>
|
|
41
48
|
ACTIVE_STATUSES.has(transition.status) &&
|
|
42
49
|
!FIX_OWNED_TRANSITION_IDS.has(transition.id)
|
|
43
50
|
)
|
|
44
|
-
.flatMap((transition) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
.flatMap((transition) => {
|
|
52
|
+
const targetSegments = [
|
|
53
|
+
...new Set(transition.symbolRenames.map((rename) => rename.to)),
|
|
54
|
+
];
|
|
55
|
+
return transition.symbolRenames.map((rename) => ({
|
|
56
|
+
rename,
|
|
57
|
+
targetSegments,
|
|
58
|
+
}));
|
|
59
|
+
});
|
|
53
60
|
|
|
54
|
-
const
|
|
61
|
+
const isIdentifierNamed = (node: AstNode, name: string): boolean =>
|
|
62
|
+
node.type === 'Identifier' && identifierName(node) === name;
|
|
63
|
+
|
|
64
|
+
const identifierTokenSpan = (
|
|
65
|
+
node: AstNode,
|
|
55
66
|
sourceCode: string,
|
|
67
|
+
name: string
|
|
68
|
+
): { readonly end: number; readonly start: number } | null => {
|
|
69
|
+
const end = node.start + name.length;
|
|
70
|
+
return sourceCode.slice(node.start, end) === name
|
|
71
|
+
? { end, start: node.start }
|
|
72
|
+
: null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const toPascalIdentifierSegment = (value: string): string =>
|
|
76
|
+
value.length === 0
|
|
77
|
+
? value
|
|
78
|
+
: `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}`;
|
|
79
|
+
|
|
80
|
+
const isScreamingSnakeIdentifier = (name: string): boolean =>
|
|
81
|
+
/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(name) && name.includes('_');
|
|
82
|
+
|
|
83
|
+
const replaceScreamingSnakeIdentifierSegment = (
|
|
84
|
+
name: string,
|
|
85
|
+
from: string,
|
|
86
|
+
to: string
|
|
87
|
+
): string | null => {
|
|
88
|
+
if (!isScreamingSnakeIdentifier(name)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fromSegment = from.toUpperCase();
|
|
93
|
+
const toSegment = to.toUpperCase();
|
|
94
|
+
let changed = false;
|
|
95
|
+
const nextName = name
|
|
96
|
+
.split('_')
|
|
97
|
+
.map((segment) => {
|
|
98
|
+
if (segment !== fromSegment) {
|
|
99
|
+
return segment;
|
|
100
|
+
}
|
|
101
|
+
changed = true;
|
|
102
|
+
return toSegment;
|
|
103
|
+
})
|
|
104
|
+
.join('_');
|
|
105
|
+
|
|
106
|
+
return changed ? nextName : null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const replaceCamelOrPascalIdentifierSegment = (
|
|
110
|
+
name: string,
|
|
111
|
+
from: string,
|
|
112
|
+
to: string
|
|
113
|
+
): string | null => {
|
|
114
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(name)) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fromPascalSegment = toPascalIdentifierSegment(from);
|
|
119
|
+
const toPascalSegment = toPascalIdentifierSegment(to);
|
|
120
|
+
const segmentPattern = /[A-Z]+(?=[A-Z][a-z]|$)|[A-Z]?[a-z]+|[0-9]+/g;
|
|
121
|
+
let cursor = 0;
|
|
122
|
+
let changed = false;
|
|
123
|
+
let nextName = '';
|
|
124
|
+
|
|
125
|
+
for (const match of name.matchAll(segmentPattern)) {
|
|
126
|
+
const [segment] = match;
|
|
127
|
+
const { index } = match;
|
|
128
|
+
if (index === undefined || index !== cursor) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (segment === from) {
|
|
133
|
+
changed = true;
|
|
134
|
+
nextName += to;
|
|
135
|
+
} else if (segment === fromPascalSegment) {
|
|
136
|
+
changed = true;
|
|
137
|
+
nextName += toPascalSegment;
|
|
138
|
+
} else {
|
|
139
|
+
nextName += segment;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
cursor = index + segment.length;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (cursor !== name.length || !changed) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return nextName;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const deriveIdentifierSegmentTarget = (
|
|
153
|
+
name: string,
|
|
154
|
+
from: string,
|
|
155
|
+
to: string
|
|
156
|
+
): string | null => {
|
|
157
|
+
const leadingUnderscores = /^_+/.exec(name)?.[0] ?? '';
|
|
158
|
+
const coreName =
|
|
159
|
+
leadingUnderscores.length > 0
|
|
160
|
+
? name.slice(leadingUnderscores.length)
|
|
161
|
+
: name;
|
|
162
|
+
if (coreName.length === 0) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const screamingSnakeTarget = replaceScreamingSnakeIdentifierSegment(
|
|
167
|
+
coreName,
|
|
168
|
+
from,
|
|
169
|
+
to
|
|
170
|
+
);
|
|
171
|
+
if (screamingSnakeTarget !== null) {
|
|
172
|
+
return `${leadingUnderscores}${screamingSnakeTarget}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const camelOrPascalTarget = replaceCamelOrPascalIdentifierSegment(
|
|
176
|
+
coreName,
|
|
177
|
+
from,
|
|
178
|
+
to
|
|
179
|
+
);
|
|
180
|
+
return camelOrPascalTarget === null
|
|
181
|
+
? null
|
|
182
|
+
: `${leadingUnderscores}${camelOrPascalTarget}`;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const hasIdentifierSegment = (name: string, segment: string): boolean =>
|
|
186
|
+
deriveIdentifierSegmentTarget(name, segment, segment) !== null;
|
|
187
|
+
|
|
188
|
+
interface SymbolRenameMatch {
|
|
189
|
+
readonly from: string;
|
|
190
|
+
readonly rename: GovernedVocabularySymbolRename;
|
|
191
|
+
readonly span: { readonly end: number; readonly start: number } | null;
|
|
192
|
+
readonly to: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const resolveSymbolRenameMatch = (
|
|
56
196
|
node: AstNode,
|
|
197
|
+
sourceCode: string,
|
|
57
198
|
rename: GovernedVocabularySymbolRename
|
|
58
|
-
):
|
|
59
|
-
|
|
60
|
-
|
|
199
|
+
): SymbolRenameMatch | null => {
|
|
200
|
+
if (rename.match === 'exact') {
|
|
201
|
+
if (!isIdentifierNamed(node, rename.from)) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
from: rename.from,
|
|
207
|
+
rename,
|
|
208
|
+
span: identifierTokenSpan(node, sourceCode, rename.from),
|
|
209
|
+
to: rename.to,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (node.type !== 'Identifier') {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const name = identifierName(node);
|
|
218
|
+
if (name === null) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const target = deriveIdentifierSegmentTarget(name, rename.from, rename.to);
|
|
222
|
+
if (target === null) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
from: name,
|
|
228
|
+
rename,
|
|
229
|
+
span: identifierTokenSpan(node, sourceCode, name),
|
|
230
|
+
to: target,
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const authoredPropertyKeyParentTypes = new Set([
|
|
235
|
+
'AccessorProperty',
|
|
236
|
+
'MethodDefinition',
|
|
237
|
+
'Property',
|
|
238
|
+
'PropertyDefinition',
|
|
239
|
+
'TSAbstractMethodDefinition',
|
|
240
|
+
'TSAbstractPropertyDefinition',
|
|
241
|
+
'TSMethodSignature',
|
|
242
|
+
'TSPropertySignature',
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
const isAuthoredMemberName = (context: AstScopeContext): boolean =>
|
|
246
|
+
(context.key === 'key' &&
|
|
247
|
+
context.parent !== null &&
|
|
248
|
+
context.parent !== undefined &&
|
|
249
|
+
authoredPropertyKeyParentTypes.has(context.parent.type)) ||
|
|
250
|
+
(context.key === 'parameter' &&
|
|
251
|
+
context.parent?.type === 'TSParameterProperty');
|
|
252
|
+
|
|
253
|
+
const isModuleBoundaryName = (context: AstScopeContext): boolean =>
|
|
254
|
+
context.parent?.type === 'ImportSpecifier' ||
|
|
255
|
+
context.parent?.type === 'ImportDefaultSpecifier' ||
|
|
256
|
+
context.parent?.type === 'ImportNamespaceSpecifier' ||
|
|
257
|
+
context.parent?.type === 'ExportSpecifier';
|
|
258
|
+
|
|
259
|
+
const reviewReasonFor = (
|
|
260
|
+
context: AstScopeContext,
|
|
261
|
+
match: SymbolRenameMatch,
|
|
262
|
+
shorthandIdentifiers: ReadonlySet<string>,
|
|
263
|
+
targetSegments: readonly string[]
|
|
264
|
+
): string | null => {
|
|
265
|
+
if (match.rename.match === 'identifier-segment') {
|
|
266
|
+
const existingTarget = targetSegments.find((segment) =>
|
|
267
|
+
hasIdentifierSegment(match.from, segment)
|
|
268
|
+
);
|
|
269
|
+
if (existingTarget !== undefined) {
|
|
270
|
+
return `already contains target segment '${existingTarget}'`;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (shorthandIdentifiers.has(match.from)) {
|
|
275
|
+
return 'participates in an authored shorthand property';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const declaration = context.getDeclaration(match.from);
|
|
279
|
+
if (
|
|
280
|
+
declaration &&
|
|
281
|
+
match.rename.reviewDeclarationTypes.includes(declaration.type)
|
|
282
|
+
) {
|
|
283
|
+
return `resolves to ${declaration.type}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (isModuleBoundaryName(context)) {
|
|
287
|
+
return 'names an import or export boundary';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (
|
|
291
|
+
context.key === 'property' &&
|
|
292
|
+
(context.parent?.type === 'MemberExpression' ||
|
|
293
|
+
context.parent?.type === 'StaticMemberExpression')
|
|
294
|
+
) {
|
|
295
|
+
return 'is a member property with no governed declaration';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (isAuthoredMemberName(context)) {
|
|
299
|
+
return 'is an authored property key';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return null;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const isDuplicateShorthandValueVisit = (
|
|
306
|
+
node: AstNode,
|
|
307
|
+
context: AstScopeContext
|
|
308
|
+
): boolean => {
|
|
309
|
+
if (context.key !== 'value' || context.parent?.type !== 'Property') {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const key = getNodeKey(context.parent);
|
|
314
|
+
return key?.start === node.start && key.end === node.end;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const isShorthandPropertyKeyVisit = (
|
|
318
|
+
node: AstNode,
|
|
319
|
+
context: AstScopeContext
|
|
320
|
+
): boolean => {
|
|
321
|
+
if (context.key !== 'key' || context.parent?.type !== 'Property') {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const value = getNodeValueNode(context.parent);
|
|
326
|
+
return value?.start === node.start && value.end === node.end;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const reviewFixFor = (
|
|
330
|
+
match: SymbolRenameMatch,
|
|
331
|
+
reviewReason: string
|
|
332
|
+
): WardenFix => ({
|
|
333
|
+
class: 'term-rewrite',
|
|
334
|
+
reason: `Retired governed symbol '${match.from}' ${reviewReason}; review before migrating to '${match.to}'.`,
|
|
335
|
+
safety: 'review',
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const safeFixFor = (match: SymbolRenameMatch): WardenFix | undefined => {
|
|
339
|
+
if (match.span === null) {
|
|
61
340
|
return undefined;
|
|
62
341
|
}
|
|
63
342
|
return {
|
|
64
343
|
class: 'term-rewrite',
|
|
65
344
|
edits: [
|
|
66
345
|
{
|
|
67
|
-
end,
|
|
68
|
-
replacement:
|
|
69
|
-
start:
|
|
346
|
+
end: match.span.end,
|
|
347
|
+
replacement: match.to,
|
|
348
|
+
start: match.span.start,
|
|
70
349
|
},
|
|
71
350
|
],
|
|
72
|
-
reason: `Retired governed symbol '${
|
|
351
|
+
reason: `Retired governed symbol '${match.from}' has a mechanical replacement '${match.to}'.`,
|
|
73
352
|
safety: 'safe',
|
|
74
353
|
};
|
|
75
354
|
};
|
|
@@ -78,14 +357,18 @@ const diagnosticFor = (
|
|
|
78
357
|
sourceCode: string,
|
|
79
358
|
filePath: string,
|
|
80
359
|
node: AstNode,
|
|
81
|
-
|
|
360
|
+
match: SymbolRenameMatch,
|
|
361
|
+
reviewReason: string | null
|
|
82
362
|
): WardenDiagnostic => {
|
|
83
|
-
const fix =
|
|
363
|
+
const fix =
|
|
364
|
+
reviewReason === null
|
|
365
|
+
? safeFixFor(match)
|
|
366
|
+
: reviewFixFor(match, reviewReason);
|
|
84
367
|
return {
|
|
85
368
|
filePath,
|
|
86
369
|
...(fix === undefined ? {} : { fix }),
|
|
87
370
|
line: offsetToLine(sourceCode, node.start),
|
|
88
|
-
message: `Retired governed symbol '${
|
|
371
|
+
message: `Retired governed symbol '${match.from}' should migrate to '${match.to}'.`,
|
|
89
372
|
rule: RULE_NAME,
|
|
90
373
|
severity: 'error',
|
|
91
374
|
};
|
|
@@ -97,8 +380,8 @@ export const governedSymbolResidue: WardenRule = {
|
|
|
97
380
|
return [];
|
|
98
381
|
}
|
|
99
382
|
|
|
100
|
-
const renames =
|
|
101
|
-
if (renames.
|
|
383
|
+
const renames = activeSymbolRenames();
|
|
384
|
+
if (renames.length === 0) {
|
|
102
385
|
return [];
|
|
103
386
|
}
|
|
104
387
|
|
|
@@ -107,19 +390,43 @@ export const governedSymbolResidue: WardenRule = {
|
|
|
107
390
|
return [];
|
|
108
391
|
}
|
|
109
392
|
|
|
110
|
-
const
|
|
111
|
-
|
|
393
|
+
const shorthandIdentifiers = new Set<string>();
|
|
394
|
+
walkWithScopeContext(parsed.ast, (node, context) => {
|
|
395
|
+
if (!isShorthandPropertyKeyVisit(node, context)) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
112
398
|
const name = identifierName(node);
|
|
113
|
-
if (name
|
|
399
|
+
if (name !== null) {
|
|
400
|
+
shorthandIdentifiers.add(name);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
405
|
+
walkWithScopeContext(parsed.ast, (node, context) => {
|
|
406
|
+
if (isDuplicateShorthandValueVisit(node, context)) {
|
|
114
407
|
return;
|
|
115
408
|
}
|
|
116
409
|
|
|
117
|
-
const
|
|
118
|
-
|
|
410
|
+
const activeMatch = renames
|
|
411
|
+
.map(({ rename, targetSegments }) => ({
|
|
412
|
+
match: resolveSymbolRenameMatch(node, sourceCode, rename),
|
|
413
|
+
targetSegments,
|
|
414
|
+
}))
|
|
415
|
+
.find((candidate) => candidate.match !== null);
|
|
416
|
+
if (activeMatch === undefined || activeMatch.match === null) {
|
|
119
417
|
return;
|
|
120
418
|
}
|
|
419
|
+
const { match, targetSegments } = activeMatch;
|
|
121
420
|
|
|
122
|
-
diagnostics.push(
|
|
421
|
+
diagnostics.push(
|
|
422
|
+
diagnosticFor(
|
|
423
|
+
sourceCode,
|
|
424
|
+
filePath,
|
|
425
|
+
node,
|
|
426
|
+
match,
|
|
427
|
+
reviewReasonFor(context, match, shorthandIdentifiers, targetSegments)
|
|
428
|
+
)
|
|
429
|
+
);
|
|
123
430
|
});
|
|
124
431
|
|
|
125
432
|
return diagnostics;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Finds implementations that return raw values instead of `Result`.
|
|
3
3
|
*
|
|
4
|
-
* Uses AST parsing to find `
|
|
4
|
+
* Uses AST parsing to find `implementation:` bodies and check that
|
|
5
5
|
* every return statement returns Result.ok(), Result.err(), ctx.compose(),
|
|
6
6
|
* or a tracked Result-typed variable.
|
|
7
7
|
*/
|
|
@@ -9,12 +9,10 @@
|
|
|
9
9
|
import { existsSync, readFileSync } from 'node:fs';
|
|
10
10
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
11
11
|
import { escapeRegExp } from '@ontrails/core';
|
|
12
|
-
import type { AstNode } from './ast.js';
|
|
13
12
|
import {
|
|
14
13
|
collectScopeFrameBindings,
|
|
15
|
-
|
|
14
|
+
findImplementationBodies,
|
|
16
15
|
findTrailDefinitions,
|
|
17
|
-
getMemberExpression,
|
|
18
16
|
getNodeAlternate,
|
|
19
17
|
getNodeArgument,
|
|
20
18
|
getNodeBodyNode,
|
|
@@ -37,7 +35,9 @@ import {
|
|
|
37
35
|
parse,
|
|
38
36
|
walk,
|
|
39
37
|
walkWithScopes,
|
|
40
|
-
} from '
|
|
38
|
+
} from '@ontrails/source';
|
|
39
|
+
import type { AstNode } from '@ontrails/source';
|
|
40
|
+
import { getMemberExpression } from './source/stores.js';
|
|
41
41
|
import { isTestFile } from './scan.js';
|
|
42
42
|
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
43
43
|
|
|
@@ -61,7 +61,7 @@ const isResultMemberCall = (callee: AstNode): boolean => {
|
|
|
61
61
|
if (objName === 'ctx' && propName === 'compose') {
|
|
62
62
|
return true;
|
|
63
63
|
}
|
|
64
|
-
return propName === '
|
|
64
|
+
return propName === 'implementation';
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
@@ -131,7 +131,7 @@ const isScopedResultVariableBinding = (
|
|
|
131
131
|
*
|
|
132
132
|
* When a non-empty `scopes` stack is provided, the namespace binding must not
|
|
133
133
|
* be shadowed by a parameter or local declaration in any enclosing scope at
|
|
134
|
-
* the call site. Without this check, any local `ns` (e.g. a
|
|
134
|
+
* the call site. Without this check, any local `ns` (e.g. a implementation parameter
|
|
135
135
|
* named `ns`, or `const ns = ...` inside the body) would be misread as the
|
|
136
136
|
* module-scope namespace import.
|
|
137
137
|
*/
|
|
@@ -1603,7 +1603,7 @@ const checkAllDefinitions = (
|
|
|
1603
1603
|
|
|
1604
1604
|
for (const def of findTrailDefinitions(ast)) {
|
|
1605
1605
|
const info = { id: def.id, label: 'Trail' };
|
|
1606
|
-
for (const implValue of
|
|
1606
|
+
for (const implValue of findImplementationBodies(def.config as AstNode)) {
|
|
1607
1607
|
checkImplementation(
|
|
1608
1608
|
implValue,
|
|
1609
1609
|
info,
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* backing resource accessor is missing the method the trail will call at
|
|
4
4
|
* runtime.
|
|
5
5
|
*
|
|
6
|
-
* The warden cannot invoke
|
|
6
|
+
* The warden cannot invoke implementations directly, but most resources declare a
|
|
7
7
|
* `mock` factory that returns a structurally real accessor for testing. We
|
|
8
8
|
* exploit that: invoke the mock with no arguments, look up the accessor by
|
|
9
|
-
*
|
|
9
|
+
* entity name (which equals every CRUD-emitted trail ID's leading
|
|
10
10
|
* segments), and inspect the method keys.
|
|
11
11
|
*
|
|
12
12
|
* The rule is intentionally forgiving — if the mock factory is missing,
|
|
@@ -38,7 +38,7 @@ const deriveOperation = (trailId: string): StandardOp | undefined => {
|
|
|
38
38
|
: undefined;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
const
|
|
41
|
+
const deriveEntityName = (trailId: string): string | undefined => {
|
|
42
42
|
const lastDot = trailId.lastIndexOf('.');
|
|
43
43
|
if (lastDot <= 0) {
|
|
44
44
|
return undefined;
|
|
@@ -105,12 +105,12 @@ const disposeMockConnection = async (
|
|
|
105
105
|
|
|
106
106
|
const resolveAccessor = (
|
|
107
107
|
connection: unknown,
|
|
108
|
-
|
|
108
|
+
entityName: string
|
|
109
109
|
): Record<string, unknown> | undefined => {
|
|
110
110
|
if (!isPlainObject(connection)) {
|
|
111
111
|
return undefined;
|
|
112
112
|
}
|
|
113
|
-
const accessor = connection[
|
|
113
|
+
const accessor = connection[entityName];
|
|
114
114
|
return isPlainObject(accessor) ? accessor : undefined;
|
|
115
115
|
};
|
|
116
116
|
|
|
@@ -124,14 +124,14 @@ const resolveAccessor = (
|
|
|
124
124
|
*/
|
|
125
125
|
const inspectAccessorMethods = async (
|
|
126
126
|
resource: AnyResource,
|
|
127
|
-
|
|
127
|
+
entityName: string
|
|
128
128
|
): Promise<ReadonlySet<string> | undefined> => {
|
|
129
129
|
const connection = await invokeMockSafely(resource);
|
|
130
130
|
if (connection === undefined) {
|
|
131
131
|
return undefined;
|
|
132
132
|
}
|
|
133
133
|
try {
|
|
134
|
-
const accessor = resolveAccessor(connection,
|
|
134
|
+
const accessor = resolveAccessor(connection, entityName);
|
|
135
135
|
if (accessor === undefined) {
|
|
136
136
|
return undefined;
|
|
137
137
|
}
|
|
@@ -157,7 +157,7 @@ const formatDiagnostic = (
|
|
|
157
157
|
interface StandardOpContext {
|
|
158
158
|
readonly trailId: string;
|
|
159
159
|
readonly operation: StandardOp;
|
|
160
|
-
readonly
|
|
160
|
+
readonly entityName: string;
|
|
161
161
|
readonly resource: AnyResource;
|
|
162
162
|
}
|
|
163
163
|
|
|
@@ -180,16 +180,16 @@ const extractStandardOpContext = (
|
|
|
180
180
|
return undefined;
|
|
181
181
|
}
|
|
182
182
|
const operation = deriveOperation(trail.id);
|
|
183
|
-
const
|
|
183
|
+
const entityName = deriveEntityName(trail.id);
|
|
184
184
|
const resource = extractSoleResource(trail);
|
|
185
185
|
if (
|
|
186
186
|
operation === undefined ||
|
|
187
|
-
|
|
187
|
+
entityName === undefined ||
|
|
188
188
|
resource === undefined
|
|
189
189
|
) {
|
|
190
190
|
return undefined;
|
|
191
191
|
}
|
|
192
|
-
return {
|
|
192
|
+
return { entityName, operation, resource, trailId: trail.id };
|
|
193
193
|
};
|
|
194
194
|
|
|
195
195
|
const diagnoseMissingMethod = (
|
|
@@ -201,7 +201,7 @@ const diagnoseMissingMethod = (
|
|
|
201
201
|
return undefined;
|
|
202
202
|
}
|
|
203
203
|
const { fallback } = expectation;
|
|
204
|
-
const base = `resource "${ctx.resource.id}" accessor "${ctx.
|
|
204
|
+
const base = `resource "${ctx.resource.id}" accessor "${ctx.entityName}"`;
|
|
205
205
|
if (fallback === undefined) {
|
|
206
206
|
return formatDiagnostic(
|
|
207
207
|
ctx.trailId,
|
|
@@ -234,7 +234,7 @@ const evaluateTrail = async (
|
|
|
234
234
|
if (ctx === undefined) {
|
|
235
235
|
return [];
|
|
236
236
|
}
|
|
237
|
-
const methods = await inspectAccessorMethods(ctx.resource, ctx.
|
|
237
|
+
const methods = await inspectAccessorMethods(ctx.resource, ctx.entityName);
|
|
238
238
|
if (methods === undefined) {
|
|
239
239
|
return [];
|
|
240
240
|
}
|
|
@@ -248,7 +248,7 @@ const evaluateTrail = async (
|
|
|
248
248
|
|
|
249
249
|
/**
|
|
250
250
|
* Topo-aware rule that flags CRUD trails whose backing accessor is missing
|
|
251
|
-
* the method invoked by the synthesized
|
|
251
|
+
* the method invoked by the synthesized implementation.
|
|
252
252
|
*
|
|
253
253
|
* @remarks
|
|
254
254
|
* Introspects each resource's `mock()` factory to determine the accessor
|
|
@@ -266,7 +266,7 @@ export const incompleteAccessorForStandardOp: TopoAwareWardenRule = {
|
|
|
266
266
|
return diagnostics;
|
|
267
267
|
},
|
|
268
268
|
description:
|
|
269
|
-
'Flag CRUD-pattern trails whose resource accessor lacks the method the synthesized
|
|
269
|
+
'Flag CRUD-pattern trails whose resource accessor lacks the method the synthesized implementation will call at runtime.',
|
|
270
270
|
name: RULE_NAME,
|
|
271
271
|
severity: 'error',
|
|
272
272
|
};
|