@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
package/src/formatters.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { WardenReport } from './cli.js';
10
+ import { staleDriftMessage } from './drift.js';
10
11
  import type { WardenGuidanceLink, WardenSeverity } from './rules/types.js';
11
12
 
12
13
  /** Map warden severity to GitHub Actions annotation level. */
@@ -35,9 +36,7 @@ export const formatGitHubAnnotations = (report: WardenReport): string => {
35
36
  if (report.drift?.blockedReason !== undefined) {
36
37
  lines.push(`::error::drift: ${report.drift.blockedReason}`);
37
38
  } else if (report.drift?.stale) {
38
- lines.push(
39
- '::error::drift: trails.lock is stale (regenerate with `trails compile`)'
40
- );
39
+ lines.push(`::error::drift: ${staleDriftMessage(report.drift)}`);
41
40
  }
42
41
 
43
42
  return lines.join('\n');
@@ -144,11 +143,7 @@ const driftSection = (drift: WardenReport['drift']): readonly string[] => {
144
143
  if (!drift?.stale) {
145
144
  return [];
146
145
  }
147
- return [
148
- '',
149
- '### Drift',
150
- '- trails.lock is stale (regenerate with `trails compile`)',
151
- ];
146
+ return ['', '### Drift', `- ${staleDriftMessage(drift)}`];
152
147
  };
153
148
 
154
149
  /** Render safe-fix counts when a fix pass was requested. */
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export type {
14
14
  ProjectContext,
15
15
  TopoAwareWardenRule,
16
16
  WardenDiagnostic,
17
+ WardenExportedSymbolDefinition,
17
18
  WardenFix,
18
19
  WardenFixCapability,
19
20
  WardenFixClass,
@@ -35,8 +36,21 @@ export type {
35
36
  // Rule registry
36
37
  export {
37
38
  builtinWardenRuleMetadata,
39
+ formatGovernedVocabularyTransitionGuide,
40
+ getGovernedVocabularyTransition,
41
+ governedVocabularyLiteralRenameSchema,
42
+ governedVocabularyPreserveRuleSchema,
43
+ governedVocabularyRegistrySchema,
44
+ governedVocabularyScopeSchema,
45
+ governedVocabularySymbolRenameSchema,
46
+ governedVocabularyTargetSchema,
47
+ governedVocabularyTransitionSchema,
48
+ governedVocabularyTransitionStatuses,
49
+ governedVocabularyTransitions,
38
50
  getWardenRuleMetadata,
39
51
  listWardenRuleMetadata,
52
+ listGovernedVocabularyTransitions,
53
+ requireGovernedVocabularyTransition,
40
54
  wardenFixClasses,
41
55
  wardenFixSafeties,
42
56
  wardenRuleConcerns,
@@ -46,6 +60,15 @@ export {
46
60
  wardenRules,
47
61
  wardenTopoRules,
48
62
  } from './rules/index.js';
63
+ export type {
64
+ GovernedVocabularyLiteralRename,
65
+ GovernedVocabularyPreserveRule,
66
+ GovernedVocabularyScope,
67
+ GovernedVocabularySymbolRename,
68
+ GovernedVocabularyTarget,
69
+ GovernedVocabularyTransition,
70
+ GovernedVocabularyTransitionInput,
71
+ } from './rules/index.js';
49
72
 
50
73
  // Rule-scoped cache controls for long-lived consumers (watch mode, LSPs).
51
74
  export { clearImplementationReturnsResultCache } from './rules/implementation-returns-result.js';
@@ -118,7 +141,7 @@ export {
118
141
  } from './formatters.js';
119
142
 
120
143
  // Drift detection
121
- export type { DriftResult } from './drift.js';
144
+ export type { CheckDriftOptions, DriftResult } from './drift.js';
122
145
  export { checkDrift } from './drift.js';
123
146
 
124
147
  // Project-local rule loading
@@ -176,6 +199,7 @@ export {
176
199
  deadPublicTrailTrail,
177
200
  deprecationWithoutGuidanceTrail,
178
201
  diagnosticSchema,
202
+ duplicateExportedSymbolTrail,
179
203
  duplicatePublicContractTrail,
180
204
  draftFileMarkingTrail,
181
205
  draftVisibleDebtTrail,
@@ -183,6 +207,7 @@ export {
183
207
  exampleValidTrail,
184
208
  firesDeclarationsTrail,
185
209
  forkWithoutPreservedBlazeTrail,
210
+ governedSymbolResidueTrail,
186
211
  implementationReturnsResultTrail,
187
212
  incompleteAccessorForStandardOpTrail,
188
213
  incompleteCrudTrail,
@@ -195,6 +220,7 @@ export {
195
220
  noDevPermitInSourceTrail,
196
221
  noDestructuredComposeTrail,
197
222
  noDirectImplementationCallTrail,
223
+ noLegacyCliAliasExportTrail,
198
224
  noLegacyLayerImportsTrail,
199
225
  noNativeErrorResultTrail,
200
226
  noRedundantResultErrorWrapTrail,
@@ -226,8 +252,10 @@ export {
226
252
  scheduledDestroyIntentTrail,
227
253
  signalGraphCoachingTrail,
228
254
  staticResourceAccessorPreferenceTrail,
229
- surfaceFacetCoherenceTrail,
255
+ surfaceOverlayCoherenceTrail,
256
+ surfaceTrailheadCoherenceTrail,
230
257
  trailForkCoachingTrail,
258
+ trailheadOverrideDivergenceTrail,
231
259
  unmaterializedActivationSourceTrail,
232
260
  topoAwareRuleInput,
233
261
  unreachableDetourShadowingTrail,
@@ -15,7 +15,26 @@ import type {
15
15
  WardenImportResolution,
16
16
  WardenResolverOptions,
17
17
  } from './resolve.js';
18
- import { offsetToLine } from './rules/ast.js';
18
+ import {
19
+ getNodeBodyStatements,
20
+ getNodeDeclaration,
21
+ getNodeDeclarations,
22
+ getNodeExportKind,
23
+ getNodeExported,
24
+ getNodeId,
25
+ getNodeLocal,
26
+ getNodeName,
27
+ getNodeSource,
28
+ getNodeSpecifiers,
29
+ getNodeValue,
30
+ isDeclarationWithId,
31
+ isExportNamedDeclaration,
32
+ isVariableDeclaration,
33
+ offsetToLine,
34
+ parse,
35
+ } from './rules/ast.js';
36
+ import type { AstNode } from './rules/ast.js';
37
+ import type { WardenExportedSymbolDefinition } from './rules/types.js';
19
38
  import { collectPublicWorkspaces } from './workspaces.js';
20
39
  import type { WardenPublicWorkspace } from './workspaces.js';
21
40
 
@@ -188,5 +207,240 @@ export const collectProjectDocumentationImportResolutions = ({
188
207
  return resolutionsByFile;
189
208
  };
190
209
 
210
+ const exportedKindForDeclaration = (
211
+ declaration: AstNode
212
+ ): WardenExportedSymbolDefinition['kind'] | null => {
213
+ if (declaration.type === 'ClassDeclaration') {
214
+ return 'class';
215
+ }
216
+ if (declaration.type === 'FunctionDeclaration') {
217
+ return 'function';
218
+ }
219
+ if (
220
+ declaration.type === 'EnumDeclaration' ||
221
+ declaration.type === 'TSEnumDeclaration'
222
+ ) {
223
+ return 'enum';
224
+ }
225
+ if (
226
+ declaration.type === 'InterfaceDeclaration' ||
227
+ declaration.type === 'TSInterfaceDeclaration'
228
+ ) {
229
+ return 'interface';
230
+ }
231
+ if (declaration.type === 'TSTypeAliasDeclaration') {
232
+ return 'type';
233
+ }
234
+ if (isVariableDeclaration(declaration)) {
235
+ return 'const';
236
+ }
237
+ return null;
238
+ };
239
+
240
+ const publicExportTargetWorkspacesByPath = (
241
+ publicWorkspaces: ReadonlyMap<string, WardenPublicWorkspace>
242
+ ): ReadonlyMap<string, WardenPublicWorkspace> => {
243
+ const workspacesByTargetPath = new Map<string, WardenPublicWorkspace>();
244
+ for (const workspace of publicWorkspaces.values()) {
245
+ for (const target of Object.values(workspace.exportTargets ?? {})) {
246
+ workspacesByTargetPath.set(normalizeRealPath(target), workspace);
247
+ }
248
+ }
249
+ return workspacesByTargetPath;
250
+ };
251
+
252
+ const readNameNode = (node: AstNode | undefined): string | null => {
253
+ if (!node) {
254
+ return null;
255
+ }
256
+ if (node.type === 'Identifier') {
257
+ return getNodeName(node) ?? null;
258
+ }
259
+ if (node.type === 'Literal' || node.type === 'StringLiteral') {
260
+ const value = getNodeValue(node);
261
+ return typeof value === 'string' ? value : null;
262
+ }
263
+ return null;
264
+ };
265
+
266
+ const exportedSpecifierKind = (
267
+ statement: AstNode,
268
+ specifier: AstNode
269
+ ): WardenExportedSymbolDefinition['kind'] =>
270
+ getNodeExportKind(statement) === 'type' ||
271
+ getNodeExportKind(specifier) === 'type'
272
+ ? 'type'
273
+ : 'export';
274
+
275
+ const reexportedDefinitions = ({
276
+ filePath,
277
+ sourceCode,
278
+ statement,
279
+ workspace,
280
+ }: {
281
+ readonly filePath: string;
282
+ readonly sourceCode: string;
283
+ readonly statement: AstNode;
284
+ readonly workspace: WardenPublicWorkspace;
285
+ }): readonly WardenExportedSymbolDefinition[] => {
286
+ const specifiers = getNodeSpecifiers(statement) ?? [];
287
+ return specifiers.flatMap((specifier) => {
288
+ if (specifier.type !== 'ExportSpecifier') {
289
+ return [];
290
+ }
291
+ const exported = getNodeExported(specifier);
292
+ const local = getNodeLocal(specifier);
293
+ const name = readNameNode(exported) ?? readNameNode(local);
294
+ return name
295
+ ? [
296
+ {
297
+ filePath,
298
+ kind: exportedSpecifierKind(statement, specifier),
299
+ line: offsetToLine(sourceCode, specifier.start),
300
+ name,
301
+ workspaceName: workspace.name,
302
+ workspaceRoot: workspace.rootDir,
303
+ } satisfies WardenExportedSymbolDefinition,
304
+ ]
305
+ : [];
306
+ });
307
+ };
308
+
309
+ const namedDeclarationDefinitions = ({
310
+ declaration,
311
+ filePath,
312
+ sourceCode,
313
+ workspace,
314
+ }: {
315
+ readonly declaration: AstNode;
316
+ readonly filePath: string;
317
+ readonly sourceCode: string;
318
+ readonly workspace: WardenPublicWorkspace;
319
+ }): readonly WardenExportedSymbolDefinition[] => {
320
+ const kind = exportedKindForDeclaration(declaration);
321
+ if (!kind) {
322
+ return [];
323
+ }
324
+
325
+ if (isVariableDeclaration(declaration)) {
326
+ return getNodeDeclarations(declaration).flatMap((declarator) => {
327
+ const name = getNodeName(getNodeId(declarator));
328
+ return name
329
+ ? [
330
+ {
331
+ filePath,
332
+ kind,
333
+ line: offsetToLine(sourceCode, declarator.start),
334
+ name,
335
+ workspaceName: workspace.name,
336
+ workspaceRoot: workspace.rootDir,
337
+ } satisfies WardenExportedSymbolDefinition,
338
+ ]
339
+ : [];
340
+ });
341
+ }
342
+
343
+ if (!isDeclarationWithId(declaration)) {
344
+ return [];
345
+ }
346
+
347
+ const name = getNodeName(getNodeId(declaration));
348
+ return name
349
+ ? [
350
+ {
351
+ filePath,
352
+ kind,
353
+ line: offsetToLine(sourceCode, declaration.start),
354
+ name,
355
+ workspaceName: workspace.name,
356
+ workspaceRoot: workspace.rootDir,
357
+ } satisfies WardenExportedSymbolDefinition,
358
+ ]
359
+ : [];
360
+ };
361
+
362
+ const collectExportedSymbolDefinitionsForFile = (
363
+ sourceFile: WardenProjectContextSourceFile,
364
+ publicExportTargetWorkspaces: ReadonlyMap<string, WardenPublicWorkspace>
365
+ ): readonly WardenExportedSymbolDefinition[] => {
366
+ if (sourceFile.kind !== 'typescript') {
367
+ return [];
368
+ }
369
+
370
+ const workspace = publicExportTargetWorkspaces.get(
371
+ normalizeRealPath(sourceFile.filePath)
372
+ );
373
+ if (!workspace) {
374
+ return [];
375
+ }
376
+
377
+ const ast = parse(sourceFile.filePath, sourceFile.sourceCode);
378
+ if (!ast) {
379
+ return [];
380
+ }
381
+
382
+ return getNodeBodyStatements(ast).flatMap((statement) => {
383
+ if (!isExportNamedDeclaration(statement)) {
384
+ return [];
385
+ }
386
+ if (getNodeSource(statement) || getNodeSpecifiers(statement)?.length) {
387
+ return reexportedDefinitions({
388
+ filePath: sourceFile.filePath,
389
+ sourceCode: sourceFile.sourceCode,
390
+ statement,
391
+ workspace,
392
+ });
393
+ }
394
+ const declaration = getNodeDeclaration(statement);
395
+ return declaration
396
+ ? namedDeclarationDefinitions({
397
+ declaration,
398
+ filePath: sourceFile.filePath,
399
+ sourceCode: sourceFile.sourceCode,
400
+ workspace,
401
+ })
402
+ : [];
403
+ });
404
+ };
405
+
406
+ export const collectProjectExportedSymbolDefinitions = ({
407
+ publicWorkspaces: providedPublicWorkspaces,
408
+ rootDir,
409
+ sourceFiles,
410
+ }: {
411
+ readonly publicWorkspaces?: ReadonlyMap<string, WardenPublicWorkspace>;
412
+ readonly rootDir: string;
413
+ readonly sourceFiles: readonly WardenProjectContextSourceFile[];
414
+ }): ReadonlyMap<string, readonly WardenExportedSymbolDefinition[]> => {
415
+ const publicWorkspaces =
416
+ providedPublicWorkspaces ?? collectPublicWorkspaces(rootDir);
417
+ const publicExportTargetWorkspaces =
418
+ publicExportTargetWorkspacesByPath(publicWorkspaces);
419
+ const definitionsByName = new Map<string, WardenExportedSymbolDefinition[]>();
420
+
421
+ for (const sourceFile of sourceFiles) {
422
+ for (const definition of collectExportedSymbolDefinitionsForFile(
423
+ sourceFile,
424
+ publicExportTargetWorkspaces
425
+ )) {
426
+ const existing = definitionsByName.get(definition.name) ?? [];
427
+ existing.push(definition);
428
+ definitionsByName.set(definition.name, existing);
429
+ }
430
+ }
431
+
432
+ return new Map(
433
+ [...definitionsByName.entries()].map(([name, definitions]) => [
434
+ name,
435
+ definitions.toSorted(
436
+ (left, right) =>
437
+ left.workspaceName.localeCompare(right.workspaceName) ||
438
+ left.filePath.localeCompare(right.filePath) ||
439
+ left.line - right.line
440
+ ),
441
+ ])
442
+ );
443
+ };
444
+
191
445
  export { collectPublicWorkspaces };
192
446
  export type { WardenPublicWorkspace } from './workspaces.js';
package/src/rules/ast.ts CHANGED
@@ -109,9 +109,11 @@ export interface VariableDeclaratorNode extends AstNode {
109
109
  export interface DeclarationWithIdNode extends AstNode {
110
110
  readonly type:
111
111
  | 'ClassDeclaration'
112
+ | 'EnumDeclaration'
112
113
  | 'FunctionDeclaration'
113
114
  | 'InterfaceDeclaration'
114
115
  | 'TSInterfaceDeclaration'
116
+ | 'TSEnumDeclaration'
115
117
  | 'TSTypeAliasDeclaration';
116
118
  readonly id?: AstNode;
117
119
  }
@@ -408,9 +410,11 @@ export const isDeclarationWithId = (
408
410
  ): node is DeclarationWithIdNode =>
409
411
  isNodeType<DeclarationWithIdNode>(node, [
410
412
  'ClassDeclaration',
413
+ 'EnumDeclaration',
411
414
  'FunctionDeclaration',
412
415
  'InterfaceDeclaration',
413
416
  'TSInterfaceDeclaration',
417
+ 'TSEnumDeclaration',
414
418
  'TSTypeAliasDeclaration',
415
419
  ]);
416
420
 
@@ -0,0 +1,211 @@
1
+ import type {
2
+ ProjectAwareWardenRule,
3
+ ProjectContext,
4
+ WardenDiagnostic,
5
+ WardenExportedSymbolDefinition,
6
+ } from './types.js';
7
+
8
+ const RULE_NAME = 'duplicate-exported-symbol';
9
+
10
+ const ALLOWED_DUPLICATE_EXPORT_GROUPS: readonly {
11
+ readonly names: readonly string[];
12
+ readonly reason: string;
13
+ readonly workspaceNames: readonly string[];
14
+ }[] = [
15
+ {
16
+ names: ['surface'],
17
+ reason: 'peer surface packages expose the same conventional entry point',
18
+ workspaceNames: [
19
+ '@ontrails/commander',
20
+ '@ontrails/hono',
21
+ '@ontrails/http',
22
+ '@ontrails/library',
23
+ '@ontrails/mcp',
24
+ ],
25
+ },
26
+ {
27
+ names: ['createApp'],
28
+ reason: 'HTTP native and Hono bindings share app-construction vocabulary',
29
+ workspaceNames: ['@ontrails/hono', '@ontrails/http'],
30
+ },
31
+ {
32
+ names: ['CreateAppOptions'],
33
+ reason: 'HTTP native and Hono bindings share option vocabulary',
34
+ workspaceNames: ['@ontrails/hono', '@ontrails/http'],
35
+ },
36
+ {
37
+ names: ['SurfaceHttpResult'],
38
+ reason: 'HTTP native and Hono bindings share runtime handle vocabulary',
39
+ workspaceNames: ['@ontrails/hono', '@ontrails/http'],
40
+ },
41
+ {
42
+ names: ['AnyTrail', 'Field'],
43
+ reason: '@ontrails/cli mirrors core command-model types for CLI consumers',
44
+ workspaceNames: ['@ontrails/cli', '@ontrails/core'],
45
+ },
46
+ {
47
+ names: [
48
+ 'Logger',
49
+ 'LogFormatter',
50
+ 'LogLevel',
51
+ 'LogRecord',
52
+ 'LogSink',
53
+ 'ObserveCapabilities',
54
+ 'ObserveConfig',
55
+ 'ObserveInput',
56
+ ],
57
+ reason: '@ontrails/observe mirrors core observability contracts',
58
+ workspaceNames: ['@ontrails/core', '@ontrails/observe'],
59
+ },
60
+ {
61
+ names: [
62
+ 'DEFAULT_MEMORY_SINK_MAX_RECORDS',
63
+ 'MemorySinkOptions',
64
+ 'MemoryTraceSink',
65
+ 'createBoundedMemorySink',
66
+ 'createMemorySink',
67
+ ],
68
+ reason:
69
+ '@ontrails/tracing compatibility memory sink mirrors @ontrails/observe',
70
+ workspaceNames: ['@ontrails/observe', '@ontrails/tracing'],
71
+ },
72
+ {
73
+ names: [
74
+ 'ActivationTraceRecordName',
75
+ 'NOOP_SINK',
76
+ 'SignalTraceRecordName',
77
+ 'TRACE_CONTEXT_KEY',
78
+ 'TraceFn',
79
+ 'clearTraceSink',
80
+ 'createActivationTraceRecord',
81
+ 'createSignalTraceRecord',
82
+ 'createTraceRecord',
83
+ 'getTraceContext',
84
+ 'getTraceSink',
85
+ 'registerTraceSink',
86
+ 'traceContextFromRecord',
87
+ 'writeActivationTraceRecord',
88
+ 'writeSignalTraceRecord',
89
+ ],
90
+ reason:
91
+ '@ontrails/tracing mirrors core tracing contracts for compatibility',
92
+ workspaceNames: ['@ontrails/core', '@ontrails/tracing'],
93
+ },
94
+ {
95
+ names: ['TraceContext', 'TraceRecord', 'TraceSink'],
96
+ reason:
97
+ '@ontrails/observe and @ontrails/tracing mirror core trace contracts',
98
+ workspaceNames: [
99
+ '@ontrails/core',
100
+ '@ontrails/observe',
101
+ '@ontrails/tracing',
102
+ ],
103
+ },
104
+ {
105
+ names: ['AuthError', 'PermitError'],
106
+ reason:
107
+ '@ontrails/core owns the TrailsError subclass; @ontrails/permits owns the auth-adapter payload type',
108
+ workspaceNames: ['@ontrails/core', '@ontrails/permits'],
109
+ },
110
+ {
111
+ names: ['Result', 'Topo', 'TrailContextInit', 'TrailInput', 'TrailOutput'],
112
+ reason: '@ontrails/library mirrors core runtime types for library users',
113
+ workspaceNames: ['@ontrails/core', '@ontrails/library'],
114
+ },
115
+ ];
116
+
117
+ const definitionKey = (definition: WardenExportedSymbolDefinition): string =>
118
+ `${definition.workspaceName}:${definition.filePath}:${definition.line}:${definition.name}`;
119
+
120
+ const isAllowedDuplicateGroup = (
121
+ definitions: readonly WardenExportedSymbolDefinition[]
122
+ ): boolean => {
123
+ const [first] = definitions;
124
+ if (!first) {
125
+ return false;
126
+ }
127
+ const workspaceNames = new Set(
128
+ definitions.map((definition) => definition.workspaceName)
129
+ );
130
+ return ALLOWED_DUPLICATE_EXPORT_GROUPS.some(
131
+ (group) =>
132
+ group.names.includes(first.name) &&
133
+ [...workspaceNames].every((name) => group.workspaceNames.includes(name))
134
+ );
135
+ };
136
+
137
+ const duplicateGroupsForFile = (
138
+ context: ProjectContext,
139
+ filePath: string
140
+ ): readonly {
141
+ readonly current: WardenExportedSymbolDefinition;
142
+ readonly definitions: readonly WardenExportedSymbolDefinition[];
143
+ }[] => {
144
+ const groups: {
145
+ current: WardenExportedSymbolDefinition;
146
+ definitions: readonly WardenExportedSymbolDefinition[];
147
+ }[] = [];
148
+
149
+ for (const definitions of context.exportedSymbolDefinitionsByName?.values() ??
150
+ []) {
151
+ const workspaceNames = new Set(
152
+ definitions.map((definition) => definition.workspaceName)
153
+ );
154
+ if (workspaceNames.size < 2) {
155
+ continue;
156
+ }
157
+ if (isAllowedDuplicateGroup(definitions)) {
158
+ continue;
159
+ }
160
+
161
+ for (const definition of definitions) {
162
+ if (definition.filePath === filePath) {
163
+ groups.push({ current: definition, definitions });
164
+ }
165
+ }
166
+ }
167
+
168
+ return groups;
169
+ };
170
+
171
+ const formatOtherDefinitions = (
172
+ current: WardenExportedSymbolDefinition,
173
+ definitions: readonly WardenExportedSymbolDefinition[]
174
+ ): string =>
175
+ definitions
176
+ .filter(
177
+ (definition) => definitionKey(definition) !== definitionKey(current)
178
+ )
179
+ .map(
180
+ (definition) =>
181
+ `${definition.workspaceName} (${definition.filePath}:${definition.line})`
182
+ )
183
+ .join(', ');
184
+
185
+ export const duplicateExportedSymbol: ProjectAwareWardenRule = {
186
+ check(): readonly WardenDiagnostic[] {
187
+ return [];
188
+ },
189
+ checkWithContext(
190
+ _sourceCode: string,
191
+ filePath: string,
192
+ context: ProjectContext
193
+ ): readonly WardenDiagnostic[] {
194
+ return duplicateGroupsForFile(context, filePath).map(
195
+ ({ current, definitions }) => ({
196
+ filePath,
197
+ line: current.line,
198
+ message: `Exported symbol "${current.name}" is defined by ${current.workspaceName} and also by ${formatOtherDefinitions(
199
+ current,
200
+ definitions
201
+ )}. Keep one package as the owner, rename one side, or document a deliberate ownership mirror before exporting both symbols.`,
202
+ rule: RULE_NAME,
203
+ severity: 'warn',
204
+ })
205
+ );
206
+ },
207
+ description:
208
+ 'Warn when the same exported symbol is defined by multiple first-party packages.',
209
+ name: RULE_NAME,
210
+ severity: 'warn',
211
+ };
@@ -30,6 +30,7 @@ const contractFingerprint = (entry: TopoGraphEntry): string =>
30
30
  JSON.stringify(
31
31
  canonicalize({
32
32
  composes: entry.composes,
33
+ contours: entry.contours,
33
34
  detours: entry.detours,
34
35
  dryRunCapable: entry.dryRunCapable,
35
36
  fires: entry.fires,
@@ -62,7 +63,7 @@ const renderTrailIds = (trailIds: readonly string[]): string =>
62
63
  const buildDiagnostic = (trailIds: readonly string[]): WardenDiagnostic => ({
63
64
  filePath: TOPO_FILE,
64
65
  line: 1,
65
- message: `Likely duplicate public trail contracts ${renderTrailIds(trailIds)} share the same input, output, intent, permits, resources, composes, signals, and detours. Keep one contract with aliases/input mappings, compose a distinct wrapper, or document why these public contracts are separate.`,
66
+ message: `Likely duplicate public trail contracts ${renderTrailIds(trailIds)} share the same input, output, intent, permits, resources, contours, composes, signals, and detours. Keep one contract with aliases/input mappings, compose a distinct wrapper, or document why these public contracts are separate.`,
66
67
  rule: RULE_NAME,
67
68
  severity: 'warn',
68
69
  });