@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.
- package/CHANGELOG.md +105 -0
- package/README.md +23 -1
- package/bin/warden.ts +1 -0
- package/package.json +9 -9
- package/src/cli.ts +160 -14
- package/src/command.ts +48 -5
- package/src/config.ts +9 -0
- package/src/drift.ts +116 -7
- package/src/fix.ts +8 -2
- package/src/formatters.ts +3 -8
- package/src/index.ts +32 -2
- package/src/project-context.ts +286 -3
- 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/implementation-returns-result.ts +1 -3
- package/src/rules/index.ts +44 -3
- package/src/rules/metadata.ts +108 -14
- package/src/rules/no-legacy-cli-alias-export.ts +247 -0
- package/src/rules/no-retired-cross-vocabulary.ts +19 -9
- package/src/rules/permit-governance.ts +1 -1
- package/src/rules/public-export-example-coverage.ts +5 -0
- package/src/rules/public-internal-deep-imports.ts +3 -63
- 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} +46 -42
- package/src/rules/trail-fork-coaching.ts +1 -1
- package/src/rules/trailhead-override-divergence.ts +356 -0
- package/src/rules/types.ts +84 -4
- 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} +5 -5
- package/src/trails/trail-fork-coaching.trail.ts +1 -1
- package/src/trails/trailhead-override-divergence.trail.ts +47 -0
- package/src/trails/wrap-rule.ts +10 -0
- package/src/workspaces.ts +30 -69
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
|
|
|
@@ -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({
|
|
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 =
|
|
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
|
+
};
|