@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +1 -1
  3. package/package.json +9 -9
  4. package/src/cli.ts +120 -10
  5. package/src/command.ts +25 -5
  6. package/src/drift.ts +116 -7
  7. package/src/fix.ts +8 -2
  8. package/src/formatters.ts +3 -8
  9. package/src/index.ts +30 -2
  10. package/src/project-context.ts +255 -1
  11. package/src/rules/ast.ts +4 -0
  12. package/src/rules/duplicate-exported-symbol.ts +211 -0
  13. package/src/rules/duplicate-public-contract.ts +2 -1
  14. package/src/rules/governed-symbol-residue.ts +131 -0
  15. package/src/rules/index.ts +43 -3
  16. package/src/rules/metadata.ts +105 -11
  17. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  18. package/src/rules/no-retired-cross-vocabulary.ts +19 -9
  19. package/src/rules/public-export-example-coverage.ts +5 -0
  20. package/src/rules/registry-names.ts +12 -2
  21. package/src/rules/retired-vocabulary.ts +396 -0
  22. package/src/rules/signal-graph-coaching.ts +32 -3
  23. package/src/rules/surface-overlay-coherence.ts +262 -0
  24. package/src/rules/{surface-facet-coherence.ts → surface-trailhead-coherence.ts} +45 -41
  25. package/src/rules/trailhead-override-divergence.ts +356 -0
  26. package/src/rules/types.ts +58 -1
  27. package/src/rules/webhook-route-collision.ts +64 -1
  28. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  29. package/src/trails/duplicate-public-contract.trail.ts +1 -1
  30. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  31. package/src/trails/index.ts +6 -1
  32. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  33. package/src/trails/schema.ts +39 -0
  34. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  35. package/src/trails/{surface-facet-coherence.trail.ts → surface-trailhead-coherence.trail.ts} +4 -4
  36. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  37. 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 (hasProducer(relations) && !hasConsumer(relations)) {
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) => buildDiagnostics(collectRelations(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,