@ontrails/warden 1.0.0-beta.32 → 1.0.0-beta.39
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 +56 -0
- package/README.md +1 -1
- package/package.json +9 -9
- package/src/cli.ts +120 -10
- package/src/command.ts +25 -5
- package/src/drift.ts +116 -7
- package/src/fix.ts +8 -2
- package/src/formatters.ts +3 -8
- package/src/index.ts +30 -2
- package/src/project-context.ts +255 -1
- package/src/rules/ast.ts +4 -0
- package/src/rules/duplicate-exported-symbol.ts +211 -0
- package/src/rules/duplicate-public-contract.ts +2 -1
- package/src/rules/governed-symbol-residue.ts +131 -0
- package/src/rules/index.ts +43 -3
- package/src/rules/metadata.ts +105 -11
- package/src/rules/no-legacy-cli-alias-export.ts +247 -0
- package/src/rules/no-retired-cross-vocabulary.ts +19 -9
- package/src/rules/public-export-example-coverage.ts +5 -0
- package/src/rules/registry-names.ts +12 -2
- package/src/rules/retired-vocabulary.ts +396 -0
- package/src/rules/signal-graph-coaching.ts +32 -3
- package/src/rules/surface-overlay-coherence.ts +262 -0
- package/src/rules/{surface-facet-coherence.ts → surface-trailhead-coherence.ts} +45 -41
- package/src/rules/trailhead-override-divergence.ts +356 -0
- package/src/rules/types.ts +58 -1
- package/src/rules/webhook-route-collision.ts +64 -1
- package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
- package/src/trails/duplicate-public-contract.trail.ts +1 -1
- package/src/trails/governed-symbol-residue.trail.ts +24 -0
- package/src/trails/index.ts +6 -1
- package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
- package/src/trails/schema.ts +39 -0
- package/src/trails/surface-overlay-coherence.trail.ts +24 -0
- package/src/trails/{surface-facet-coherence.trail.ts → surface-trailhead-coherence.trail.ts} +4 -4
- package/src/trails/trailhead-override-divergence.trail.ts +47 -0
- package/src/trails/wrap-rule.ts +10 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const governedVocabularyTransitionStatuses = [
|
|
4
|
+
'planned',
|
|
5
|
+
'active',
|
|
6
|
+
'complete',
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
const governedVocabularySingleTargetSchema = z.object({
|
|
10
|
+
kind: z.literal('single'),
|
|
11
|
+
to: z.string().min(1),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const governedVocabularyClassifiedTargetSchema = z.object({
|
|
15
|
+
guidance: z.string().min(1),
|
|
16
|
+
kind: z.literal('classified'),
|
|
17
|
+
options: z
|
|
18
|
+
.array(
|
|
19
|
+
z.object({
|
|
20
|
+
to: z.string().min(1),
|
|
21
|
+
when: z.string().min(1),
|
|
22
|
+
})
|
|
23
|
+
)
|
|
24
|
+
.min(1),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const governedVocabularyTargetSchema = z.discriminatedUnion('kind', [
|
|
28
|
+
governedVocabularySingleTargetSchema,
|
|
29
|
+
governedVocabularyClassifiedTargetSchema,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export const governedVocabularyPreserveRuleSchema = z.object({
|
|
33
|
+
paths: z.array(z.string().min(1)).optional(),
|
|
34
|
+
pattern: z.string().min(1),
|
|
35
|
+
reason: z.string().min(1),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const governedVocabularyScopeSchema = z.object({
|
|
39
|
+
exclude: z.array(z.string().min(1)).optional(),
|
|
40
|
+
extensions: z.array(z.string().min(1)).optional(),
|
|
41
|
+
ignoredDirectories: z.array(z.string().min(1)).optional(),
|
|
42
|
+
include: z.array(z.string().min(1)).optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const governedVocabularySymbolRenameSchema = z.object({
|
|
46
|
+
from: z.string().min(1),
|
|
47
|
+
reviewDeclarationTypes: z.array(z.string().min(1)).default([]),
|
|
48
|
+
to: z.string().min(1),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const governedVocabularyLiteralRenameSchema = z.object({
|
|
52
|
+
from: z.string().min(1),
|
|
53
|
+
to: z.string().min(1),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const governedVocabularyTransitionSchema = z.object({
|
|
57
|
+
codeIdentifiers: z.array(z.string().min(1)).default([]),
|
|
58
|
+
docs: z.object({
|
|
59
|
+
guidance: z.array(z.string().min(1)).default([]),
|
|
60
|
+
summary: z.string().min(1),
|
|
61
|
+
}),
|
|
62
|
+
from: z.string().min(1),
|
|
63
|
+
id: z.string().min(1),
|
|
64
|
+
intent: z.string().min(1),
|
|
65
|
+
kind: z.literal('vocabulary'),
|
|
66
|
+
oldForms: z.array(z.string().min(1)).min(1),
|
|
67
|
+
overrides: z.record(z.string().min(1), z.string().min(1)).default({}),
|
|
68
|
+
preserve: z.array(governedVocabularyPreserveRuleSchema).default([]),
|
|
69
|
+
reviewForms: z.array(z.string().min(1)).default([]),
|
|
70
|
+
safeRewriteForms: z.record(z.string().min(1), z.string().min(1)).default({}),
|
|
71
|
+
scope: governedVocabularyScopeSchema.optional(),
|
|
72
|
+
status: z.enum(governedVocabularyTransitionStatuses),
|
|
73
|
+
stringLiteralRenames: z
|
|
74
|
+
.array(governedVocabularyLiteralRenameSchema)
|
|
75
|
+
.default([]),
|
|
76
|
+
symbolRenames: z.array(governedVocabularySymbolRenameSchema).default([]),
|
|
77
|
+
target: governedVocabularyTargetSchema,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const governedVocabularyRegistrySchema = z
|
|
81
|
+
.array(governedVocabularyTransitionSchema)
|
|
82
|
+
.superRefine((transitions, ctx) => {
|
|
83
|
+
const seenIds = new Set<string>();
|
|
84
|
+
const seenFrom = new Set<string>();
|
|
85
|
+
|
|
86
|
+
for (const [index, transition] of transitions.entries()) {
|
|
87
|
+
if (seenIds.has(transition.id)) {
|
|
88
|
+
ctx.addIssue({
|
|
89
|
+
code: 'custom',
|
|
90
|
+
message: `Duplicate governed vocabulary transition id "${transition.id}".`,
|
|
91
|
+
path: [index, 'id'],
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
seenIds.add(transition.id);
|
|
95
|
+
|
|
96
|
+
if (seenFrom.has(transition.from)) {
|
|
97
|
+
ctx.addIssue({
|
|
98
|
+
code: 'custom',
|
|
99
|
+
message: `Duplicate governed vocabulary source "${transition.from}".`,
|
|
100
|
+
path: [index, 'from'],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
seenFrom.add(transition.from);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export type GovernedVocabularyPreserveRule = z.output<
|
|
108
|
+
typeof governedVocabularyPreserveRuleSchema
|
|
109
|
+
>;
|
|
110
|
+
export type GovernedVocabularyScope = z.output<
|
|
111
|
+
typeof governedVocabularyScopeSchema
|
|
112
|
+
>;
|
|
113
|
+
export type GovernedVocabularyTarget = z.output<
|
|
114
|
+
typeof governedVocabularyTargetSchema
|
|
115
|
+
>;
|
|
116
|
+
export type GovernedVocabularySymbolRename = z.output<
|
|
117
|
+
typeof governedVocabularySymbolRenameSchema
|
|
118
|
+
>;
|
|
119
|
+
export type GovernedVocabularyLiteralRename = z.output<
|
|
120
|
+
typeof governedVocabularyLiteralRenameSchema
|
|
121
|
+
>;
|
|
122
|
+
export type GovernedVocabularyTransition = z.output<
|
|
123
|
+
typeof governedVocabularyTransitionSchema
|
|
124
|
+
>;
|
|
125
|
+
export type GovernedVocabularyTransitionInput = z.input<
|
|
126
|
+
typeof governedVocabularyTransitionSchema
|
|
127
|
+
>;
|
|
128
|
+
|
|
129
|
+
const defineTransition = (
|
|
130
|
+
input: GovernedVocabularyTransitionInput
|
|
131
|
+
): GovernedVocabularyTransition =>
|
|
132
|
+
governedVocabularyTransitionSchema.parse(input);
|
|
133
|
+
|
|
134
|
+
const reviewFunctionParamDeclarations = {
|
|
135
|
+
reviewDeclarationTypes: ['FunctionParam'],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const v1VocabularyHistoricalExcludes = [
|
|
139
|
+
'.agents/memory/**',
|
|
140
|
+
'.agents/plans/archive/**',
|
|
141
|
+
'.changeset/**',
|
|
142
|
+
'**/CHANGELOG.md',
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const v1VocabularySelfExcludes = [
|
|
146
|
+
'packages/warden/src/rules/retired-vocabulary.ts',
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const unique = (values: readonly string[]): string[] => [...new Set(values)];
|
|
150
|
+
|
|
151
|
+
const defineV1Transition = (
|
|
152
|
+
input: GovernedVocabularyTransitionInput
|
|
153
|
+
): GovernedVocabularyTransition =>
|
|
154
|
+
defineTransition({
|
|
155
|
+
...input,
|
|
156
|
+
scope: {
|
|
157
|
+
...input.scope,
|
|
158
|
+
exclude: unique([
|
|
159
|
+
...v1VocabularyHistoricalExcludes,
|
|
160
|
+
...v1VocabularySelfExcludes,
|
|
161
|
+
...(input.scope?.exclude ?? []),
|
|
162
|
+
]),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
export const governedVocabularyTransitions =
|
|
167
|
+
governedVocabularyRegistrySchema.parse([
|
|
168
|
+
defineTransition({
|
|
169
|
+
codeIdentifiers: ['ctx.cross', 'crossInput', 'crosses'],
|
|
170
|
+
docs: {
|
|
171
|
+
guidance: [
|
|
172
|
+
'Exact ctx.cross, crossInput, and crosses forms are mechanically rewritten.',
|
|
173
|
+
'Broader cross or Cross forms route to review because a text rule cannot prove the intended symbol boundary.',
|
|
174
|
+
],
|
|
175
|
+
summary:
|
|
176
|
+
'Beta.19 retired cross composition vocabulary in favor of compose.',
|
|
177
|
+
},
|
|
178
|
+
from: 'cross',
|
|
179
|
+
id: 'cross-compose',
|
|
180
|
+
intent:
|
|
181
|
+
'Retire beta.19 cross composition vocabulary in favor of compose.',
|
|
182
|
+
kind: 'vocabulary',
|
|
183
|
+
oldForms: ['cross', 'Cross', 'crosses', 'crossInput', 'ctx.cross'],
|
|
184
|
+
reviewForms: ['Cross', 'cross'],
|
|
185
|
+
safeRewriteForms: {
|
|
186
|
+
crossInput: 'composeInput',
|
|
187
|
+
crosses: 'composes',
|
|
188
|
+
'ctx.cross': 'ctx.compose',
|
|
189
|
+
},
|
|
190
|
+
status: 'complete',
|
|
191
|
+
symbolRenames: [
|
|
192
|
+
{ ...reviewFunctionParamDeclarations, from: 'cross', to: 'compose' },
|
|
193
|
+
{
|
|
194
|
+
...reviewFunctionParamDeclarations,
|
|
195
|
+
from: 'crossInput',
|
|
196
|
+
to: 'composeInput',
|
|
197
|
+
},
|
|
198
|
+
{ ...reviewFunctionParamDeclarations, from: 'crosses', to: 'composes' },
|
|
199
|
+
],
|
|
200
|
+
target: { kind: 'single', to: 'compose' },
|
|
201
|
+
}),
|
|
202
|
+
defineV1Transition({
|
|
203
|
+
codeIdentifiers: ['blaze', 'blazes'],
|
|
204
|
+
docs: {
|
|
205
|
+
guidance: [
|
|
206
|
+
'Treat code/API identifiers as governed symbols, not prose-only vocabulary.',
|
|
207
|
+
'Review inflected forms such as blazed or blazing instead of forcing a mechanical rewrite.',
|
|
208
|
+
],
|
|
209
|
+
summary:
|
|
210
|
+
'The authored trail behavior field is moving from blaze to implementation.',
|
|
211
|
+
},
|
|
212
|
+
from: 'blaze',
|
|
213
|
+
id: 'v1-blaze-implementation',
|
|
214
|
+
intent:
|
|
215
|
+
'Move the authored trail behavior field from blaze to implementation for v1.',
|
|
216
|
+
kind: 'vocabulary',
|
|
217
|
+
oldForms: ['blaze', 'blazes', 'Blaze'],
|
|
218
|
+
reviewForms: ['Blaze', 'blazing', 'blazed', 'trailblaze'],
|
|
219
|
+
safeRewriteForms: {
|
|
220
|
+
blaze: 'implementation',
|
|
221
|
+
blazes: 'implementations',
|
|
222
|
+
},
|
|
223
|
+
status: 'planned',
|
|
224
|
+
symbolRenames: [
|
|
225
|
+
{
|
|
226
|
+
...reviewFunctionParamDeclarations,
|
|
227
|
+
from: 'blaze',
|
|
228
|
+
to: 'implementation',
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
...reviewFunctionParamDeclarations,
|
|
232
|
+
from: 'blazes',
|
|
233
|
+
to: 'implementations',
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
target: { kind: 'single', to: 'implementation' },
|
|
237
|
+
}),
|
|
238
|
+
defineV1Transition({
|
|
239
|
+
codeIdentifiers: ['contour', 'contours'],
|
|
240
|
+
docs: {
|
|
241
|
+
guidance: [
|
|
242
|
+
'Keep domain-object semantics distinct from entities in app data until the occurrence is classified.',
|
|
243
|
+
],
|
|
244
|
+
summary:
|
|
245
|
+
'The domain object declaration term is moving from contour to entity.',
|
|
246
|
+
},
|
|
247
|
+
from: 'contour',
|
|
248
|
+
id: 'v1-contour-entity',
|
|
249
|
+
intent:
|
|
250
|
+
'Move the domain object declaration vocabulary from contour to entity for v1.',
|
|
251
|
+
kind: 'vocabulary',
|
|
252
|
+
oldForms: ['contour', 'contours', 'Contour'],
|
|
253
|
+
reviewForms: ['Contour'],
|
|
254
|
+
safeRewriteForms: {
|
|
255
|
+
contour: 'entity',
|
|
256
|
+
contours: 'entities',
|
|
257
|
+
},
|
|
258
|
+
status: 'planned',
|
|
259
|
+
symbolRenames: [
|
|
260
|
+
{ ...reviewFunctionParamDeclarations, from: 'contour', to: 'entity' },
|
|
261
|
+
{
|
|
262
|
+
...reviewFunctionParamDeclarations,
|
|
263
|
+
from: 'contours',
|
|
264
|
+
to: 'entities',
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
target: { kind: 'single', to: 'entity' },
|
|
268
|
+
}),
|
|
269
|
+
defineV1Transition({
|
|
270
|
+
codeIdentifiers: [
|
|
271
|
+
'facets',
|
|
272
|
+
'facetId',
|
|
273
|
+
'McpSurfaceFacetMap',
|
|
274
|
+
'surface-facet-coherence',
|
|
275
|
+
'wayfind.facets',
|
|
276
|
+
],
|
|
277
|
+
docs: {
|
|
278
|
+
guidance: [
|
|
279
|
+
'A trailhead is one grouped surface entry fronting several trails while preserving member identity.',
|
|
280
|
+
'Code/API identifiers remain governed symbols and need structured preservation before broad application.',
|
|
281
|
+
],
|
|
282
|
+
summary:
|
|
283
|
+
'The grouped surface entry term is moving from facet to trailhead.',
|
|
284
|
+
},
|
|
285
|
+
from: 'facet',
|
|
286
|
+
id: 'v1-facet-trailhead',
|
|
287
|
+
intent:
|
|
288
|
+
'Move grouped surface entry vocabulary from facet to trailhead for v1.',
|
|
289
|
+
kind: 'vocabulary',
|
|
290
|
+
oldForms: ['facet', 'facets', 'Facet'],
|
|
291
|
+
reviewForms: ['Facet'],
|
|
292
|
+
safeRewriteForms: {
|
|
293
|
+
facet: 'trailhead',
|
|
294
|
+
facets: 'trailheads',
|
|
295
|
+
},
|
|
296
|
+
status: 'complete',
|
|
297
|
+
stringLiteralRenames: [
|
|
298
|
+
{ from: 'surface-facet-coherence', to: 'surface-trailhead-coherence' },
|
|
299
|
+
{ from: 'wayfind.facets', to: 'wayfind.trailheads' },
|
|
300
|
+
],
|
|
301
|
+
symbolRenames: [
|
|
302
|
+
{ ...reviewFunctionParamDeclarations, from: 'facet', to: 'trailhead' },
|
|
303
|
+
{
|
|
304
|
+
...reviewFunctionParamDeclarations,
|
|
305
|
+
from: 'facets',
|
|
306
|
+
to: 'trailheads',
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
...reviewFunctionParamDeclarations,
|
|
310
|
+
from: 'facetId',
|
|
311
|
+
to: 'trailheadId',
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
...reviewFunctionParamDeclarations,
|
|
315
|
+
from: 'McpSurfaceFacetMap',
|
|
316
|
+
to: 'McpSurfaceTrailheadMap',
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
target: { kind: 'single', to: 'trailhead' },
|
|
320
|
+
}),
|
|
321
|
+
defineV1Transition({
|
|
322
|
+
codeIdentifiers: [],
|
|
323
|
+
docs: {
|
|
324
|
+
guidance: [
|
|
325
|
+
'Use derive for contract-owned fact production.',
|
|
326
|
+
'Use render for surface- or operator-facing presentation.',
|
|
327
|
+
'Route every occurrence to review until the stage is classified.',
|
|
328
|
+
],
|
|
329
|
+
summary: 'Projection vocabulary splits by stage into derive or render.',
|
|
330
|
+
},
|
|
331
|
+
from: 'projection',
|
|
332
|
+
id: 'v1-projection-derive-render',
|
|
333
|
+
intent:
|
|
334
|
+
'Split projection vocabulary into derive/render by lifecycle stage for v1.',
|
|
335
|
+
kind: 'vocabulary',
|
|
336
|
+
oldForms: ['projection', 'projections', 'project', 'projected'],
|
|
337
|
+
reviewForms: ['projection', 'projections', 'project', 'projected'],
|
|
338
|
+
safeRewriteForms: {},
|
|
339
|
+
status: 'planned',
|
|
340
|
+
target: {
|
|
341
|
+
guidance:
|
|
342
|
+
'No single replacement is safe. Classify by whether the occurrence produces contract facts or presents derived facts.',
|
|
343
|
+
kind: 'classified',
|
|
344
|
+
options: [
|
|
345
|
+
{
|
|
346
|
+
to: 'derive',
|
|
347
|
+
when: 'The occurrence describes producing contract-owned facts or inferred data from authored inputs.',
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
to: 'render',
|
|
351
|
+
when: 'The occurrence describes presenting derived facts through a surface, report, guide, or operator output.',
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
},
|
|
355
|
+
}),
|
|
356
|
+
]);
|
|
357
|
+
|
|
358
|
+
export const listGovernedVocabularyTransitions =
|
|
359
|
+
(): readonly GovernedVocabularyTransition[] => governedVocabularyTransitions;
|
|
360
|
+
|
|
361
|
+
export const getGovernedVocabularyTransition = (
|
|
362
|
+
id: string
|
|
363
|
+
): GovernedVocabularyTransition | undefined =>
|
|
364
|
+
governedVocabularyTransitions.find((transition) => transition.id === id);
|
|
365
|
+
|
|
366
|
+
export const requireGovernedVocabularyTransition = (
|
|
367
|
+
id: string
|
|
368
|
+
): GovernedVocabularyTransition => {
|
|
369
|
+
const transition = getGovernedVocabularyTransition(id);
|
|
370
|
+
if (transition === undefined) {
|
|
371
|
+
throw new Error(`Unknown governed vocabulary transition "${id}".`);
|
|
372
|
+
}
|
|
373
|
+
return transition;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
export const formatGovernedVocabularyTransitionGuide = (
|
|
377
|
+
transitions: readonly GovernedVocabularyTransition[] = governedVocabularyTransitions
|
|
378
|
+
): string =>
|
|
379
|
+
transitions
|
|
380
|
+
.map((transition) => {
|
|
381
|
+
const target =
|
|
382
|
+
transition.target.kind === 'single'
|
|
383
|
+
? transition.target.to
|
|
384
|
+
: transition.target.options
|
|
385
|
+
.map((option) => `${option.to} (${option.when})`)
|
|
386
|
+
.join(' or ');
|
|
387
|
+
const lines = [
|
|
388
|
+
`- ${transition.id}: ${transition.from} -> ${target}`,
|
|
389
|
+
` - Status: ${transition.status}`,
|
|
390
|
+
` - Intent: ${transition.intent}`,
|
|
391
|
+
` - Safe rewrites: ${Object.keys(transition.safeRewriteForms).length}`,
|
|
392
|
+
` - Review forms: ${transition.reviewForms.join(', ') || 'none'}`,
|
|
393
|
+
];
|
|
394
|
+
return lines.join('\n');
|
|
395
|
+
})
|
|
396
|
+
.join('\n');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getLateBoundSignalRef } from '@ontrails/core';
|
|
1
2
|
import type { Topo } from '@ontrails/core';
|
|
2
3
|
|
|
3
4
|
import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
|
|
@@ -70,6 +71,25 @@ const collectProducerResources = (
|
|
|
70
71
|
);
|
|
71
72
|
};
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Signal ids whose contracts are store-derived advertisements.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* Store resources advertise `created`/`updated`/`removed` signals for every
|
|
79
|
+
* table as available capability. Leaving them unconsumed is a legitimate
|
|
80
|
+
* steady state, so produced-without-consumer coaching would be pure noise for
|
|
81
|
+
* store-backed apps.
|
|
82
|
+
*/
|
|
83
|
+
const collectStoreDerivedSignalIds = (topo: Topo): ReadonlySet<string> =>
|
|
84
|
+
new Set(
|
|
85
|
+
topo
|
|
86
|
+
.listSignals()
|
|
87
|
+
.filter(
|
|
88
|
+
(signal) => getLateBoundSignalRef(signal)?.kind === 'store-derived'
|
|
89
|
+
)
|
|
90
|
+
.map((signal) => signal.id)
|
|
91
|
+
);
|
|
92
|
+
|
|
73
93
|
const collectConsumers = (
|
|
74
94
|
topo: Topo
|
|
75
95
|
): ReadonlyMap<string, readonly string[]> => {
|
|
@@ -162,7 +182,8 @@ const hasConsumer = ({ consumers }: SignalRelations): boolean =>
|
|
|
162
182
|
consumers.length > 0;
|
|
163
183
|
|
|
164
184
|
const buildDiagnostics = (
|
|
165
|
-
relationsBySignal: ReadonlyMap<string, SignalRelations
|
|
185
|
+
relationsBySignal: ReadonlyMap<string, SignalRelations>,
|
|
186
|
+
storeDerivedSignalIds: ReadonlySet<string>
|
|
166
187
|
): readonly WardenDiagnostic[] => {
|
|
167
188
|
const diagnostics: WardenDiagnostic[] = [];
|
|
168
189
|
|
|
@@ -172,7 +193,11 @@ const buildDiagnostics = (
|
|
|
172
193
|
continue;
|
|
173
194
|
}
|
|
174
195
|
|
|
175
|
-
if (
|
|
196
|
+
if (
|
|
197
|
+
hasProducer(relations) &&
|
|
198
|
+
!hasConsumer(relations) &&
|
|
199
|
+
!storeDerivedSignalIds.has(signalId)
|
|
200
|
+
) {
|
|
176
201
|
diagnostics.push(
|
|
177
202
|
buildProducedWithoutConsumerDiagnostic(signalId, relations)
|
|
178
203
|
);
|
|
@@ -183,7 +208,11 @@ const buildDiagnostics = (
|
|
|
183
208
|
};
|
|
184
209
|
|
|
185
210
|
export const signalGraphCoaching: TopoAwareWardenRule = {
|
|
186
|
-
checkTopo: (topo) =>
|
|
211
|
+
checkTopo: (topo) =>
|
|
212
|
+
buildDiagnostics(
|
|
213
|
+
collectRelations(topo),
|
|
214
|
+
collectStoreDerivedSignalIds(topo)
|
|
215
|
+
),
|
|
187
216
|
description:
|
|
188
217
|
'Warn when typed signal contracts are declared or produced without reactive consumers.',
|
|
189
218
|
name: RULE_NAME,
|