@ontrails/warden 1.0.0-beta.30 → 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 (45) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +23 -1
  3. package/bin/warden.ts +1 -0
  4. package/package.json +9 -9
  5. package/src/cli.ts +160 -14
  6. package/src/command.ts +48 -5
  7. package/src/config.ts +9 -0
  8. package/src/drift.ts +116 -7
  9. package/src/fix.ts +8 -2
  10. package/src/formatters.ts +3 -8
  11. package/src/index.ts +32 -2
  12. package/src/project-context.ts +286 -3
  13. package/src/rules/ast.ts +4 -0
  14. package/src/rules/duplicate-exported-symbol.ts +211 -0
  15. package/src/rules/duplicate-public-contract.ts +2 -1
  16. package/src/rules/governed-symbol-residue.ts +131 -0
  17. package/src/rules/implementation-returns-result.ts +1 -3
  18. package/src/rules/index.ts +44 -3
  19. package/src/rules/metadata.ts +108 -14
  20. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  21. package/src/rules/no-retired-cross-vocabulary.ts +19 -9
  22. package/src/rules/permit-governance.ts +1 -1
  23. package/src/rules/public-export-example-coverage.ts +5 -0
  24. package/src/rules/public-internal-deep-imports.ts +3 -63
  25. package/src/rules/registry-names.ts +12 -2
  26. package/src/rules/retired-vocabulary.ts +396 -0
  27. package/src/rules/signal-graph-coaching.ts +32 -3
  28. package/src/rules/surface-overlay-coherence.ts +262 -0
  29. package/src/rules/{surface-facet-coherence.ts → surface-trailhead-coherence.ts} +46 -42
  30. package/src/rules/trail-fork-coaching.ts +1 -1
  31. package/src/rules/trailhead-override-divergence.ts +356 -0
  32. package/src/rules/types.ts +84 -4
  33. package/src/rules/webhook-route-collision.ts +64 -1
  34. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  35. package/src/trails/duplicate-public-contract.trail.ts +1 -1
  36. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  37. package/src/trails/index.ts +6 -1
  38. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  39. package/src/trails/schema.ts +39 -0
  40. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  41. package/src/trails/{surface-facet-coherence.trail.ts → surface-trailhead-coherence.trail.ts} +5 -5
  42. package/src/trails/trail-fork-coaching.trail.ts +1 -1
  43. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  44. package/src/trails/wrap-rule.ts +10 -0
  45. package/src/workspaces.ts +30 -69
@@ -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
 
@@ -81,16 +100,42 @@ const exportAliasesForWorkspaces = (
81
100
  return aliases;
82
101
  };
83
102
 
103
+ const resolveOptionsWithWorkspaceAliases = (
104
+ publicWorkspaces: ReadonlyMap<string, WardenPublicWorkspace> | undefined,
105
+ resolveOptions: WardenResolverOptions['resolveOptions'] | undefined
106
+ ): WardenResolverOptions['resolveOptions'] => {
107
+ if (!publicWorkspaces) {
108
+ return resolveOptions;
109
+ }
110
+
111
+ const workspaceAliases = exportAliasesForWorkspaces(publicWorkspaces);
112
+ return {
113
+ ...resolveOptions,
114
+ alias: {
115
+ ...workspaceAliases,
116
+ ...resolveOptions?.alias,
117
+ },
118
+ };
119
+ };
120
+
84
121
  export const collectProjectImportResolutions = ({
122
+ publicWorkspaces,
85
123
  resolveOptions,
86
124
  rootDir,
87
125
  sourceFiles,
88
126
  }: {
127
+ readonly publicWorkspaces?: ReadonlyMap<string, WardenPublicWorkspace>;
89
128
  readonly resolveOptions?: WardenResolverOptions['resolveOptions'];
90
129
  readonly rootDir: string;
91
130
  readonly sourceFiles: readonly WardenProjectContextSourceFile[];
92
131
  }): ReadonlyMap<string, readonly WardenImportResolution[]> => {
93
- const resolver = createWardenResolver({ resolveOptions, rootDir });
132
+ const resolver = createWardenResolver({
133
+ resolveOptions: resolveOptionsWithWorkspaceAliases(
134
+ publicWorkspaces,
135
+ resolveOptions
136
+ ),
137
+ rootDir,
138
+ });
94
139
  const resolutionsByFile = new Map<
95
140
  string,
96
141
  readonly WardenImportResolution[]
@@ -118,13 +163,16 @@ export const collectProjectImportResolutions = ({
118
163
  };
119
164
 
120
165
  export const collectProjectDocumentationImportResolutions = ({
166
+ publicWorkspaces: providedPublicWorkspaces,
121
167
  rootDir,
122
168
  sourceFiles,
123
169
  }: {
170
+ readonly publicWorkspaces?: ReadonlyMap<string, WardenPublicWorkspace>;
124
171
  readonly rootDir: string;
125
172
  readonly sourceFiles: readonly WardenProjectContextSourceFile[];
126
173
  }): ReadonlyMap<string, readonly WardenImportResolution[]> => {
127
- const publicWorkspaces = collectPublicWorkspaces(rootDir);
174
+ const publicWorkspaces =
175
+ providedPublicWorkspaces ?? collectPublicWorkspaces(rootDir);
128
176
  const resolver = createWardenResolver({
129
177
  resolveOptions: { alias: exportAliasesForWorkspaces(publicWorkspaces) },
130
178
  rootDir,
@@ -159,5 +207,240 @@ export const collectProjectDocumentationImportResolutions = ({
159
207
  return resolutionsByFile;
160
208
  };
161
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
+
162
445
  export { collectPublicWorkspaces };
163
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
  });
@@ -0,0 +1,131 @@
1
+ import {
2
+ identifierName,
3
+ offsetToLine,
4
+ parseWithDiagnostics,
5
+ walk,
6
+ } from './ast.js';
7
+ import type { AstNode } from './ast.js';
8
+ import { listGovernedVocabularyTransitions } from './retired-vocabulary.js';
9
+ import type {
10
+ GovernedVocabularySymbolRename,
11
+ GovernedVocabularyTransition,
12
+ } from './retired-vocabulary.js';
13
+ import type { WardenDiagnostic, WardenFix, WardenRule } from './types.js';
14
+
15
+ const RULE_NAME = 'governed-symbol-residue';
16
+
17
+ const ACTIVE_STATUSES = new Set<GovernedVocabularyTransition['status']>([
18
+ 'active',
19
+ 'complete',
20
+ ]);
21
+
22
+ const FIX_OWNED_TRANSITION_IDS = new Set(['cross-compose']);
23
+
24
+ const ALLOWED_SOURCE_PATH_SUFFIXES = [
25
+ '/packages/warden/src/rules/governed-symbol-residue.ts',
26
+ '/packages/warden/src/rules/retired-vocabulary.ts',
27
+ ] as const;
28
+
29
+ const normalizePath = (filePath: string): string =>
30
+ filePath.replaceAll('\\', '/');
31
+
32
+ const isAllowedSourcePath = (filePath: string): boolean =>
33
+ ALLOWED_SOURCE_PATH_SUFFIXES.some((suffix) =>
34
+ normalizePath(filePath).endsWith(suffix)
35
+ );
36
+
37
+ const activeSymbolRenames = (): readonly GovernedVocabularySymbolRename[] =>
38
+ listGovernedVocabularyTransitions()
39
+ .filter(
40
+ (transition) =>
41
+ ACTIVE_STATUSES.has(transition.status) &&
42
+ !FIX_OWNED_TRANSITION_IDS.has(transition.id)
43
+ )
44
+ .flatMap((transition) => transition.symbolRenames);
45
+
46
+ const renameBySourceSymbol = (): ReadonlyMap<
47
+ string,
48
+ GovernedVocabularySymbolRename
49
+ > =>
50
+ new Map(
51
+ activeSymbolRenames().map((rename) => [rename.from, rename] as const)
52
+ );
53
+
54
+ const safeFixFor = (
55
+ sourceCode: string,
56
+ node: AstNode,
57
+ rename: GovernedVocabularySymbolRename
58
+ ): WardenFix | undefined => {
59
+ const end = node.start + rename.from.length;
60
+ if (sourceCode.slice(node.start, end) !== rename.from) {
61
+ return undefined;
62
+ }
63
+ return {
64
+ class: 'term-rewrite',
65
+ edits: [
66
+ {
67
+ end,
68
+ replacement: rename.to,
69
+ start: node.start,
70
+ },
71
+ ],
72
+ reason: `Retired governed symbol '${rename.from}' has a mechanical replacement '${rename.to}'.`,
73
+ safety: 'safe',
74
+ };
75
+ };
76
+
77
+ const diagnosticFor = (
78
+ sourceCode: string,
79
+ filePath: string,
80
+ node: AstNode,
81
+ rename: GovernedVocabularySymbolRename
82
+ ): WardenDiagnostic => {
83
+ const fix = safeFixFor(sourceCode, node, rename);
84
+ return {
85
+ filePath,
86
+ ...(fix === undefined ? {} : { fix }),
87
+ line: offsetToLine(sourceCode, node.start),
88
+ message: `Retired governed symbol '${rename.from}' should migrate to '${rename.to}'.`,
89
+ rule: RULE_NAME,
90
+ severity: 'error',
91
+ };
92
+ };
93
+
94
+ export const governedSymbolResidue: WardenRule = {
95
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
96
+ if (isAllowedSourcePath(filePath)) {
97
+ return [];
98
+ }
99
+
100
+ const renames = renameBySourceSymbol();
101
+ if (renames.size === 0) {
102
+ return [];
103
+ }
104
+
105
+ const parsed = parseWithDiagnostics(filePath, sourceCode);
106
+ if (!parsed.ast || parsed.diagnostics.length > 0) {
107
+ return [];
108
+ }
109
+
110
+ const diagnostics: WardenDiagnostic[] = [];
111
+ walk(parsed.ast, (node) => {
112
+ const name = identifierName(node);
113
+ if (name === null) {
114
+ return;
115
+ }
116
+
117
+ const rename = renames.get(name);
118
+ if (rename === undefined) {
119
+ return;
120
+ }
121
+
122
+ diagnostics.push(diagnosticFor(sourceCode, filePath, node, rename));
123
+ });
124
+
125
+ return diagnostics;
126
+ },
127
+ description:
128
+ 'Detect active governed vocabulary symbols that remain in source code.',
129
+ name: RULE_NAME,
130
+ severity: 'error',
131
+ };