@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
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
|
-
|
|
255
|
+
surfaceOverlayCoherenceTrail,
|
|
256
|
+
surfaceTrailheadCoherenceTrail,
|
|
230
257
|
trailForkCoachingTrail,
|
|
258
|
+
trailheadOverrideDivergenceTrail,
|
|
231
259
|
unmaterializedActivationSourceTrail,
|
|
232
260
|
topoAwareRuleInput,
|
|
233
261
|
unreachableDetourShadowingTrail,
|
package/src/project-context.ts
CHANGED
|
@@ -15,7 +15,26 @@ import type {
|
|
|
15
15
|
WardenImportResolution,
|
|
16
16
|
WardenResolverOptions,
|
|
17
17
|
} from './resolve.js';
|
|
18
|
-
import {
|
|
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
|
});
|