@ontrails/warden 1.0.0-beta.4 → 1.0.0-beta.42
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 +837 -13
- package/README.md +133 -26
- package/bin/warden.ts +51 -0
- package/package.json +27 -6
- package/src/adapter-check.ts +136 -0
- package/src/cli.ts +1570 -105
- package/src/command.ts +986 -0
- package/src/config.ts +193 -0
- package/src/draft.ts +22 -0
- package/src/drift.ts +233 -23
- package/src/fix.ts +126 -0
- package/src/formatters.ts +78 -13
- package/src/guide.ts +245 -0
- package/src/index.ts +247 -15
- package/src/project-context.ts +446 -0
- package/src/project-rules.ts +290 -0
- package/src/resolve.ts +531 -0
- package/src/rules/activation-orphan.ts +97 -0
- package/src/rules/captured-kernel.ts +375 -0
- package/src/rules/circular-refs.ts +150 -0
- package/src/rules/cli-command-route-coherence.ts +177 -0
- package/src/rules/composes-declarations.ts +839 -0
- package/src/rules/context-no-surface-types.ts +79 -15
- package/src/rules/dead-internal-trail.ts +161 -0
- package/src/rules/dead-public-trail.ts +258 -0
- package/src/rules/draft-file-marking.ts +155 -0
- package/src/rules/draft-visible-debt.ts +82 -0
- package/src/rules/duplicate-exported-symbol.ts +211 -0
- package/src/rules/duplicate-public-contract.ts +137 -0
- package/src/rules/entity-exists.ts +254 -0
- package/src/rules/entity-ids.ts +15 -0
- package/src/rules/error-mapping-completeness.ts +290 -0
- package/src/rules/example-valid.ts +395 -0
- package/src/rules/fires-declarations.ts +740 -0
- package/src/rules/governed-symbol-residue.ts +438 -0
- package/src/rules/implementation-returns-result.ts +1409 -166
- package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
- package/src/rules/incomplete-crud.ts +583 -0
- package/src/rules/index.ts +277 -10
- package/src/rules/intent-propagation.ts +125 -0
- package/src/rules/layer-field-name-drift.ts +102 -0
- package/src/rules/library-projection-coherence.ts +100 -0
- package/src/rules/metadata.ts +871 -0
- package/src/rules/missing-reconcile.ts +97 -0
- package/src/rules/missing-visibility.ts +111 -0
- package/src/rules/no-destructured-compose.ts +196 -0
- package/src/rules/no-dev-permit-in-source.ts +99 -0
- package/src/rules/no-direct-implementation-call.ts +12 -7
- package/src/rules/no-legacy-cli-alias-export.ts +247 -0
- package/src/rules/no-legacy-layer-imports.ts +211 -0
- package/src/rules/no-native-error-result.ts +118 -0
- package/src/rules/no-redundant-result-error-wrap.ts +384 -0
- package/src/rules/no-retired-cross-vocabulary.ts +204 -0
- package/src/rules/no-sync-result-assumption.ts +1141 -98
- package/src/rules/no-throw-in-detour-recover.ts +225 -0
- package/src/rules/no-throw-in-implementation.ts +15 -8
- package/src/rules/no-top-level-surface.ts +371 -0
- package/src/rules/on-references-exist.ts +194 -0
- package/src/rules/orphaned-signal.ts +149 -0
- package/src/rules/owner-projection-parity.ts +143 -0
- package/src/rules/permit-governance.ts +25 -0
- package/src/rules/public-export-example-coverage.ts +573 -0
- package/src/rules/public-internal-deep-imports.ts +456 -0
- package/src/rules/public-output-schema.ts +29 -0
- package/src/rules/public-union-output-discriminants.ts +150 -0
- package/src/rules/read-intent-fires.ts +188 -0
- package/src/rules/reference-exists.ts +97 -0
- package/src/rules/registry-names.ts +167 -0
- package/src/rules/resolved-import-boundary.ts +146 -0
- package/src/rules/resource-declarations.ts +697 -0
- package/src/rules/resource-exists.ts +181 -0
- package/src/rules/resource-id-grammar.ts +65 -0
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/retired-vocabulary.ts +633 -0
- package/src/rules/scan.ts +38 -25
- package/src/rules/scheduled-destroy-intent.ts +44 -0
- package/src/rules/signal-graph-coaching.ts +220 -0
- package/src/rules/source/composition.ts +165 -0
- package/src/rules/source/drafts.ts +164 -0
- package/src/rules/source/entities.ts +618 -0
- package/src/rules/source/pragmas.ts +45 -0
- package/src/rules/source/resources.ts +64 -0
- package/src/rules/source/signals.ts +397 -0
- package/src/rules/source/stores.ts +310 -0
- package/src/rules/specs.ts +9 -5
- package/src/rules/static-resource-accessor-preference.ts +653 -0
- package/src/rules/surface-overlay-coherence.ts +262 -0
- package/src/rules/surface-trailhead-coherence.ts +366 -0
- package/src/rules/trail-fork-coaching.ts +625 -0
- package/src/rules/trail-versioning-source.ts +1076 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/trailhead-override-divergence.ts +356 -0
- package/src/rules/types.ts +354 -8
- package/src/rules/unmaterialized-activation-source.ts +85 -0
- package/src/rules/unreachable-detour-shadowing.ts +339 -0
- package/src/rules/valid-describe-refs.ts +162 -32
- package/src/rules/valid-detour-contract.ts +78 -0
- package/src/rules/warden-export-symmetry.ts +540 -0
- package/src/rules/warden-rules-use-ast.ts +1109 -0
- package/src/rules/webhook-route-collision.ts +306 -0
- package/src/trails/activation-orphan.trail.ts +84 -0
- package/src/trails/captured-kernel.trail.ts +108 -0
- package/src/trails/circular-refs.trail.ts +29 -0
- package/src/trails/cli-command-route-coherence.trail.ts +47 -0
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/context-no-surface-types.trail.ts +21 -0
- package/src/trails/dead-internal-trail.trail.ts +26 -0
- package/src/trails/dead-public-trail.trail.ts +31 -0
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/draft-file-marking.trail.ts +16 -0
- package/src/trails/draft-visible-debt.trail.ts +16 -0
- package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
- package/src/trails/duplicate-public-contract.trail.ts +47 -0
- package/src/trails/entity-exists.trail.ts +21 -0
- package/src/trails/error-mapping-completeness.trail.ts +30 -0
- package/src/trails/example-valid.trail.ts +25 -0
- package/src/trails/fires-declarations.trail.ts +23 -0
- package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
- package/src/trails/governed-symbol-residue.trail.ts +24 -0
- package/src/trails/implementation-returns-result.trail.ts +20 -0
- package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
- package/src/trails/incomplete-crud.trail.ts +39 -0
- package/src/trails/index.ts +89 -0
- package/src/trails/intent-propagation.trail.ts +30 -0
- package/src/trails/layer-field-name-drift.trail.ts +39 -0
- package/src/trails/library-projection-coherence.trail.ts +43 -0
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-reconcile.trail.ts +33 -0
- package/src/trails/missing-visibility.trail.ts +22 -0
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
- package/src/trails/no-direct-implementation-call.trail.ts +16 -0
- package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
- package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
- package/src/trails/no-native-error-result.trail.ts +18 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
- package/src/trails/no-sync-result-assumption.trail.ts +19 -0
- package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
- package/src/trails/no-throw-in-implementation.trail.ts +20 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/on-references-exist.trail.ts +21 -0
- package/src/trails/orphaned-signal.trail.ts +36 -0
- package/src/trails/owner-projection-parity.trail.ts +26 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/permit-governance.trail.ts +51 -0
- package/src/trails/prefer-schema-inference.trail.ts +21 -0
- package/src/trails/public-export-example-coverage.trail.ts +16 -0
- package/src/trails/public-internal-deep-imports.trail.ts +94 -0
- package/src/trails/public-output-schema.trail.ts +55 -0
- package/src/trails/public-union-output-discriminants.trail.ts +33 -0
- package/src/trails/read-intent-fires.trail.ts +20 -0
- package/src/trails/reference-exists.trail.ts +25 -0
- package/src/trails/resolved-import-boundary.trail.ts +109 -0
- package/src/trails/resource-declarations.trail.ts +25 -0
- package/src/trails/resource-exists.trail.ts +27 -0
- package/src/trails/resource-id-grammar.trail.ts +39 -0
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +160 -0
- package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
- package/src/trails/schema.ts +237 -0
- package/src/trails/signal-graph-coaching.trail.ts +77 -0
- package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
- package/src/trails/surface-overlay-coherence.trail.ts +24 -0
- package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
- package/src/trails/topo.ts +6 -0
- package/src/trails/trail-fork-coaching.trail.ts +42 -0
- package/src/trails/trailhead-override-divergence.trail.ts +47 -0
- package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
- package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
- package/src/trails/valid-describe-refs.trail.ts +18 -0
- package/src/trails/valid-detour-contract.trail.ts +71 -0
- package/src/trails/version-gap.trail.ts +35 -0
- package/src/trails/version-pinned-compose.trail.ts +23 -0
- package/src/trails/version-without-examples.trail.ts +38 -0
- package/src/trails/warden-export-symmetry.trail.ts +16 -0
- package/src/trails/warden-rules-use-ast.trail.ts +64 -0
- package/src/trails/webhook-route-collision.trail.ts +50 -0
- package/src/trails/wrap-rule.ts +224 -0
- package/src/workspaces.ts +199 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/cli.d.ts +0 -46
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -218
- package/dist/cli.js.map +0 -1
- package/dist/drift.d.ts +0 -26
- package/dist/drift.d.ts.map +0 -1
- package/dist/drift.js +0 -27
- package/dist/drift.js.map +0 -1
- package/dist/formatters.d.ts +0 -29
- package/dist/formatters.d.ts.map +0 -1
- package/dist/formatters.js +0 -87
- package/dist/formatters.js.map +0 -1
- package/dist/index.d.ts +0 -26
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -26
- package/dist/index.js.map +0 -1
- package/dist/rules/ast.d.ts +0 -41
- package/dist/rules/ast.d.ts.map +0 -1
- package/dist/rules/ast.js +0 -161
- package/dist/rules/ast.js.map +0 -1
- package/dist/rules/context-no-surface-types.d.ts +0 -12
- package/dist/rules/context-no-surface-types.d.ts.map +0 -1
- package/dist/rules/context-no-surface-types.js +0 -96
- package/dist/rules/context-no-surface-types.js.map +0 -1
- package/dist/rules/implementation-returns-result.d.ts +0 -13
- package/dist/rules/implementation-returns-result.d.ts.map +0 -1
- package/dist/rules/implementation-returns-result.js +0 -277
- package/dist/rules/implementation-returns-result.js.map +0 -1
- package/dist/rules/index.d.ts +0 -15
- package/dist/rules/index.d.ts.map +0 -1
- package/dist/rules/index.js +0 -34
- package/dist/rules/index.js.map +0 -1
- package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
- package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
- package/dist/rules/no-direct-impl-in-route.js +0 -47
- package/dist/rules/no-direct-impl-in-route.js.map +0 -1
- package/dist/rules/no-direct-implementation-call.d.ts +0 -12
- package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
- package/dist/rules/no-direct-implementation-call.js +0 -39
- package/dist/rules/no-direct-implementation-call.js.map +0 -1
- package/dist/rules/no-sync-result-assumption.d.ts +0 -6
- package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
- package/dist/rules/no-sync-result-assumption.js +0 -98
- package/dist/rules/no-sync-result-assumption.js.map +0 -1
- package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
- package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
- package/dist/rules/no-throw-in-detour-target.js +0 -87
- package/dist/rules/no-throw-in-detour-target.js.map +0 -1
- package/dist/rules/no-throw-in-implementation.d.ts +0 -9
- package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
- package/dist/rules/no-throw-in-implementation.js +0 -34
- package/dist/rules/no-throw-in-implementation.js.map +0 -1
- package/dist/rules/prefer-schema-inference.d.ts +0 -7
- package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
- package/dist/rules/prefer-schema-inference.js +0 -86
- package/dist/rules/prefer-schema-inference.js.map +0 -1
- package/dist/rules/scan.d.ts +0 -8
- package/dist/rules/scan.d.ts.map +0 -1
- package/dist/rules/scan.js +0 -32
- package/dist/rules/scan.js.map +0 -1
- package/dist/rules/specs.d.ts +0 -29
- package/dist/rules/specs.d.ts.map +0 -1
- package/dist/rules/specs.js +0 -192
- package/dist/rules/specs.js.map +0 -1
- package/dist/rules/structure.d.ts +0 -13
- package/dist/rules/structure.d.ts.map +0 -1
- package/dist/rules/structure.js +0 -142
- package/dist/rules/structure.js.map +0 -1
- package/dist/rules/types.d.ts +0 -52
- package/dist/rules/types.d.ts.map +0 -1
- package/dist/rules/types.js +0 -2
- package/dist/rules/types.js.map +0 -1
- package/dist/rules/valid-describe-refs.d.ts +0 -7
- package/dist/rules/valid-describe-refs.d.ts.map +0 -1
- package/dist/rules/valid-describe-refs.js +0 -51
- package/dist/rules/valid-describe-refs.js.map +0 -1
- package/dist/rules/valid-detour-refs.d.ts +0 -6
- package/dist/rules/valid-detour-refs.d.ts.map +0 -1
- package/dist/rules/valid-detour-refs.js +0 -116
- package/dist/rules/valid-detour-refs.js.map +0 -1
- package/src/__tests__/cli.test.ts +0 -198
- package/src/__tests__/drift.test.ts +0 -74
- package/src/__tests__/formatters.test.ts +0 -157
- package/src/__tests__/implementation-returns-result.test.ts +0 -129
- package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
- package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
- package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
- package/src/__tests__/prefer-schema-inference.test.ts +0 -84
- package/src/__tests__/rules.test.ts +0 -227
- package/src/__tests__/valid-describe-refs.test.ts +0 -60
- package/src/rules/ast.ts +0 -213
- package/src/rules/no-direct-impl-in-route.ts +0 -81
- package/src/rules/no-throw-in-detour-target.ts +0 -150
- package/src/rules/valid-detour-refs.ts +0 -187
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
extractStringLiteral,
|
|
6
|
+
getNodeDeclaration,
|
|
7
|
+
getNodeImportKind,
|
|
8
|
+
getNodeLocal,
|
|
9
|
+
getNodeSource,
|
|
10
|
+
getNodeSpecifiers,
|
|
11
|
+
identifierName,
|
|
12
|
+
isExportDefaultDeclaration,
|
|
13
|
+
isExportNamedDeclaration,
|
|
14
|
+
isImportDeclaration,
|
|
15
|
+
offsetToLine,
|
|
16
|
+
parse,
|
|
17
|
+
walk,
|
|
18
|
+
} from '@ontrails/source';
|
|
19
|
+
import type { WardenImportResolution } from '../resolve.js';
|
|
20
|
+
import type { WardenPublicWorkspace } from '../workspaces.js';
|
|
21
|
+
import { hasIgnoreCommentOnLine, splitSourceLines } from './source/pragmas.js';
|
|
22
|
+
import { isTestFile } from './scan.js';
|
|
23
|
+
import type {
|
|
24
|
+
ProjectAwareWardenRule,
|
|
25
|
+
ProjectContext,
|
|
26
|
+
WardenDiagnostic,
|
|
27
|
+
} from './types.js';
|
|
28
|
+
|
|
29
|
+
const RULE_NAME = 'captured-kernel';
|
|
30
|
+
|
|
31
|
+
interface InternalReExportSite {
|
|
32
|
+
readonly importSource: string;
|
|
33
|
+
readonly line: number;
|
|
34
|
+
readonly resolutionLine: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ImportedBindingSite {
|
|
38
|
+
readonly importSource: string;
|
|
39
|
+
readonly resolutionLine: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ExportTarget {
|
|
43
|
+
readonly specifier: string;
|
|
44
|
+
readonly workspace: WardenPublicWorkspace;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const normalizePath = (path: string): string => path.replaceAll('\\', '/');
|
|
48
|
+
|
|
49
|
+
const normalizeRealPath = (path: string): string => {
|
|
50
|
+
try {
|
|
51
|
+
return normalizePath(realpathSync(path));
|
|
52
|
+
} catch {
|
|
53
|
+
return normalizePath(resolve(path));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const pathIsInside = (filePath: string, rootDir: string): boolean => {
|
|
58
|
+
const absoluteFilePath = normalizeRealPath(filePath);
|
|
59
|
+
const absoluteRootDir = normalizeRealPath(rootDir);
|
|
60
|
+
return (
|
|
61
|
+
absoluteFilePath === absoluteRootDir ||
|
|
62
|
+
absoluteFilePath.startsWith(`${absoluteRootDir}/`)
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const sourcePackageNameForFile = (
|
|
67
|
+
filePath: string,
|
|
68
|
+
workspaces: ReadonlyMap<string, WardenPublicWorkspace>
|
|
69
|
+
): string | undefined => {
|
|
70
|
+
for (const workspace of workspaces.values()) {
|
|
71
|
+
if (pathIsInside(filePath, workspace.rootDir)) {
|
|
72
|
+
return workspace.name;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const exportTargetsForFile = (
|
|
79
|
+
filePath: string,
|
|
80
|
+
workspaces: ReadonlyMap<string, WardenPublicWorkspace>
|
|
81
|
+
): readonly ExportTarget[] => {
|
|
82
|
+
const normalizedFilePath = normalizeRealPath(filePath);
|
|
83
|
+
const targets: ExportTarget[] = [];
|
|
84
|
+
for (const workspace of workspaces.values()) {
|
|
85
|
+
for (const [specifier, target] of Object.entries(
|
|
86
|
+
workspace.exportTargets ?? {}
|
|
87
|
+
)) {
|
|
88
|
+
if (specifier === workspace.name) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (normalizeRealPath(target) === normalizedFilePath) {
|
|
92
|
+
targets.push({ specifier, workspace });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return targets.toSorted((left, right) =>
|
|
97
|
+
left.specifier.localeCompare(right.specifier)
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const collectReExportSites = (
|
|
102
|
+
sourceCode: string,
|
|
103
|
+
filePath: string
|
|
104
|
+
): readonly InternalReExportSite[] => {
|
|
105
|
+
const ast = parse(filePath, sourceCode);
|
|
106
|
+
if (!ast) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sites: InternalReExportSite[] = [];
|
|
111
|
+
const importedBindings = new Map<string, ImportedBindingSite>();
|
|
112
|
+
walk(ast, (node) => {
|
|
113
|
+
if (!isImportDeclaration(node)) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (getNodeImportKind(node) === 'type') {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const value = extractStringLiteral(getNodeSource(node));
|
|
120
|
+
if (typeof value !== 'string') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const resolutionLine = offsetToLine(sourceCode, node.start);
|
|
124
|
+
for (const specifier of getNodeSpecifiers(node) ?? []) {
|
|
125
|
+
if (getNodeImportKind(specifier) === 'type') {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const localName = identifierName(getNodeLocal(specifier));
|
|
129
|
+
if (localName !== null) {
|
|
130
|
+
importedBindings.set(localName, {
|
|
131
|
+
importSource: value,
|
|
132
|
+
resolutionLine,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const siteKeys = new Set<string>();
|
|
139
|
+
const addSite = (site: InternalReExportSite): void => {
|
|
140
|
+
const key = `${site.importSource}:${site.line}:${site.resolutionLine}`;
|
|
141
|
+
if (!siteKeys.has(key)) {
|
|
142
|
+
siteKeys.add(key);
|
|
143
|
+
sites.push(site);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
walk(ast, (node) => {
|
|
147
|
+
if (isExportDefaultDeclaration(node)) {
|
|
148
|
+
const localName = identifierName(getNodeDeclaration(node));
|
|
149
|
+
if (localName === null) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const imported = importedBindings.get(localName);
|
|
153
|
+
if (imported !== undefined) {
|
|
154
|
+
addSite({
|
|
155
|
+
...imported,
|
|
156
|
+
line: offsetToLine(sourceCode, node.start),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (
|
|
163
|
+
node.type !== 'ExportNamedDeclaration' &&
|
|
164
|
+
node.type !== 'ExportAllDeclaration'
|
|
165
|
+
) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const line = offsetToLine(sourceCode, node.start);
|
|
170
|
+
const value = extractStringLiteral(getNodeSource(node));
|
|
171
|
+
if (typeof value === 'string') {
|
|
172
|
+
addSite({ importSource: value, line, resolutionLine: line });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!isExportNamedDeclaration(node)) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (const specifier of getNodeSpecifiers(node) ?? []) {
|
|
180
|
+
const localName = identifierName(getNodeLocal(specifier));
|
|
181
|
+
if (localName === null) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const imported = importedBindings.get(localName);
|
|
185
|
+
if (imported !== undefined) {
|
|
186
|
+
addSite({ ...imported, line });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
return sites.toSorted((left, right) => left.line - right.line);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const importResolutionsForFile = (
|
|
194
|
+
context: ProjectContext,
|
|
195
|
+
filePath: string
|
|
196
|
+
): readonly WardenImportResolution[] =>
|
|
197
|
+
context.importResolutionsByFile?.get(filePath) ?? [];
|
|
198
|
+
|
|
199
|
+
const reExportResolution = (
|
|
200
|
+
resolutions: readonly WardenImportResolution[],
|
|
201
|
+
site: InternalReExportSite
|
|
202
|
+
): WardenImportResolution | undefined =>
|
|
203
|
+
resolutions.find(
|
|
204
|
+
(resolution) =>
|
|
205
|
+
resolution.importSource === site.importSource &&
|
|
206
|
+
resolution.line === site.resolutionLine
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const isOwnedInternalReExport = (
|
|
210
|
+
workspace: WardenPublicWorkspace,
|
|
211
|
+
resolution: WardenImportResolution | undefined
|
|
212
|
+
): boolean => {
|
|
213
|
+
if (!resolution?.isInternalTarget) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
if (resolution.packageName === workspace.name) {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
resolution.packageRoot &&
|
|
221
|
+
normalizeRealPath(resolution.packageRoot) ===
|
|
222
|
+
normalizeRealPath(workspace.rootDir)
|
|
223
|
+
) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
return resolution.resolvedPath
|
|
227
|
+
? pathIsInside(resolution.resolvedPath, workspace.rootDir)
|
|
228
|
+
: false;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const isNonProductionEvidenceFile = (filePath: string): boolean => {
|
|
232
|
+
const normalized = normalizePath(filePath);
|
|
233
|
+
return (
|
|
234
|
+
isTestFile(filePath) ||
|
|
235
|
+
/(?:^|\/)(?:__fixtures__|fixtures?|migrations?|historical|changelogs?|changesets?|agent-notes?)(?:\/|$)/.test(
|
|
236
|
+
normalized
|
|
237
|
+
) ||
|
|
238
|
+
/(?:^|\/)\.(?:changeset|agents)(?:\/|$)/.test(normalized) ||
|
|
239
|
+
/(?:^|\/)(?:CHANGELOG|changeset)\.md$/i.test(normalized)
|
|
240
|
+
);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const externalProductionConsumerPackages = ({
|
|
244
|
+
context,
|
|
245
|
+
exportSpecifier,
|
|
246
|
+
hostPackage,
|
|
247
|
+
workspaces,
|
|
248
|
+
}: {
|
|
249
|
+
readonly context: ProjectContext;
|
|
250
|
+
readonly exportSpecifier: string;
|
|
251
|
+
readonly hostPackage: string;
|
|
252
|
+
readonly workspaces: ReadonlyMap<string, WardenPublicWorkspace>;
|
|
253
|
+
}): readonly string[] => {
|
|
254
|
+
const consumers = new Set<string>();
|
|
255
|
+
for (const resolutions of context.importResolutionsByFile?.values() ?? []) {
|
|
256
|
+
for (const resolution of resolutions) {
|
|
257
|
+
if (resolution.importSource !== exportSpecifier) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (resolution.errorKind || !resolution.usesPublicExport) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!resolution.crossesPackageBoundary) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (resolution.packageName !== hostPackage) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (isNonProductionEvidenceFile(resolution.importerPath)) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const sourcePackageName = sourcePackageNameForFile(
|
|
274
|
+
resolution.importerPath,
|
|
275
|
+
workspaces
|
|
276
|
+
);
|
|
277
|
+
if (!sourcePackageName || sourcePackageName === hostPackage) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
consumers.add(sourcePackageName);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return [...consumers].toSorted();
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const diagnosticMessage = ({
|
|
287
|
+
consumers,
|
|
288
|
+
exportSpecifier,
|
|
289
|
+
hostPackage,
|
|
290
|
+
importSource,
|
|
291
|
+
}: {
|
|
292
|
+
readonly consumers: readonly string[];
|
|
293
|
+
readonly exportSpecifier: string;
|
|
294
|
+
readonly hostPackage: string;
|
|
295
|
+
readonly importSource: string;
|
|
296
|
+
}): string =>
|
|
297
|
+
`${hostPackage} export target "${exportSpecifier}" re-exports internal target "${importSource}" and is consumed by external production packages ${consumers.join(
|
|
298
|
+
', '
|
|
299
|
+
)}. Review ownership of the exported subpath and its captured kernel before it becomes a durable public seam.`;
|
|
300
|
+
|
|
301
|
+
export const capturedKernel: ProjectAwareWardenRule = {
|
|
302
|
+
check(): readonly WardenDiagnostic[] {
|
|
303
|
+
return [];
|
|
304
|
+
},
|
|
305
|
+
checkWithContext(
|
|
306
|
+
sourceCode: string,
|
|
307
|
+
filePath: string,
|
|
308
|
+
context: ProjectContext
|
|
309
|
+
): readonly WardenDiagnostic[] {
|
|
310
|
+
const workspaces = context.publicWorkspaces;
|
|
311
|
+
if (!workspaces || workspaces.size === 0) {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const exportTargets = exportTargetsForFile(filePath, workspaces);
|
|
316
|
+
if (exportTargets.length === 0) {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const lines = splitSourceLines(sourceCode);
|
|
321
|
+
const resolutions = importResolutionsForFile(context, filePath);
|
|
322
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
323
|
+
|
|
324
|
+
for (const exportTarget of exportTargets) {
|
|
325
|
+
const consumers = externalProductionConsumerPackages({
|
|
326
|
+
context,
|
|
327
|
+
exportSpecifier: exportTarget.specifier,
|
|
328
|
+
hostPackage: exportTarget.workspace.name,
|
|
329
|
+
workspaces,
|
|
330
|
+
});
|
|
331
|
+
if (consumers.length < 2) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const site of collectReExportSites(sourceCode, filePath)) {
|
|
336
|
+
if (hasIgnoreCommentOnLine(lines, site.line)) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const resolution = reExportResolution(resolutions, site);
|
|
341
|
+
if (!isOwnedInternalReExport(exportTarget.workspace, resolution)) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
diagnostics.push({
|
|
346
|
+
filePath,
|
|
347
|
+
guidance: {
|
|
348
|
+
steps: [
|
|
349
|
+
'Review whether the public subpath should become an owned package surface, move back behind the package root, or be split into a better-owned package.',
|
|
350
|
+
'If the imported capability is reusable source-code machinery, serves at least two independently owned toolchain capabilities, exposes one genuinely shared contract, and owns no verdict, migration plan, graph query, or surface rendering, consider relocating it to @ontrails/source.',
|
|
351
|
+
'Otherwise, preserve the current owner or choose another doctrinal owner.',
|
|
352
|
+
],
|
|
353
|
+
summary:
|
|
354
|
+
'Review ownership before an internal re-exported kernel hardens into a public package seam.',
|
|
355
|
+
},
|
|
356
|
+
line: site.line,
|
|
357
|
+
message: diagnosticMessage({
|
|
358
|
+
consumers,
|
|
359
|
+
exportSpecifier: exportTarget.specifier,
|
|
360
|
+
hostPackage: exportTarget.workspace.name,
|
|
361
|
+
importSource: site.importSource,
|
|
362
|
+
}),
|
|
363
|
+
rule: RULE_NAME,
|
|
364
|
+
severity: 'warn',
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return diagnostics;
|
|
370
|
+
},
|
|
371
|
+
description:
|
|
372
|
+
'Flag public subpath exports that capture internal kernels after multiple production packages consume them.',
|
|
373
|
+
name: RULE_NAME,
|
|
374
|
+
severity: 'warn',
|
|
375
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { collectEntityReferenceTargetsByName } from './source/entities.js';
|
|
2
|
+
import { findEntityDefinitions, offsetToLine, parse } from '@ontrails/source';
|
|
3
|
+
import type { AstNode } from '@ontrails/source';
|
|
4
|
+
import { isTestFile } from './scan.js';
|
|
5
|
+
import type {
|
|
6
|
+
ProjectAwareWardenRule,
|
|
7
|
+
ProjectContext,
|
|
8
|
+
WardenDiagnostic,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
|
|
11
|
+
const mergeReferenceGraphs = (
|
|
12
|
+
localGraph: ReadonlyMap<string, readonly string[]>,
|
|
13
|
+
contextGraph?: ReadonlyMap<string, readonly string[]>
|
|
14
|
+
): ReadonlyMap<string, readonly string[]> => {
|
|
15
|
+
const merged = new Map<string, Set<string>>();
|
|
16
|
+
|
|
17
|
+
const addTargets = (source: string, targets: readonly string[]): void => {
|
|
18
|
+
const existing = merged.get(source);
|
|
19
|
+
if (existing) {
|
|
20
|
+
for (const target of targets) {
|
|
21
|
+
existing.add(target);
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
merged.set(source, new Set(targets));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
for (const [source, targets] of contextGraph ?? []) {
|
|
30
|
+
addTargets(source, targets);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const [source, targets] of localGraph) {
|
|
34
|
+
addTargets(source, targets);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return new Map(
|
|
38
|
+
[...merged.entries()].map(([source, targets]) => [source, [...targets]])
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const findCyclePath = (
|
|
43
|
+
start: string,
|
|
44
|
+
graph: ReadonlyMap<string, readonly string[]>,
|
|
45
|
+
current = start,
|
|
46
|
+
path: readonly string[] = [start],
|
|
47
|
+
active: ReadonlySet<string> = new Set([start])
|
|
48
|
+
): readonly string[] | null => {
|
|
49
|
+
for (const target of graph.get(current) ?? []) {
|
|
50
|
+
if (target === start) {
|
|
51
|
+
return [...path, target];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (active.has(target)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cycle = findCyclePath(
|
|
59
|
+
start,
|
|
60
|
+
graph,
|
|
61
|
+
target,
|
|
62
|
+
[...path, target],
|
|
63
|
+
new Set([...active, target])
|
|
64
|
+
);
|
|
65
|
+
if (cycle) {
|
|
66
|
+
return cycle;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const buildCircularReferenceDiagnostic = (
|
|
74
|
+
entityName: string,
|
|
75
|
+
cyclePath: readonly string[],
|
|
76
|
+
filePath: string,
|
|
77
|
+
line: number
|
|
78
|
+
): WardenDiagnostic => ({
|
|
79
|
+
filePath,
|
|
80
|
+
line,
|
|
81
|
+
message: `Entity "${entityName}" participates in circular entity references: ${cyclePath.join(' -> ')}. Break the cycle by removing one entity reference, or extract the shared shape into a new entity neither side depends on.`,
|
|
82
|
+
rule: 'circular-refs',
|
|
83
|
+
severity: 'warn',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const checkCircularReferences = (
|
|
87
|
+
ast: AstNode,
|
|
88
|
+
sourceCode: string,
|
|
89
|
+
filePath: string,
|
|
90
|
+
graph: ReadonlyMap<string, readonly string[]>
|
|
91
|
+
): readonly WardenDiagnostic[] => {
|
|
92
|
+
if (isTestFile(filePath)) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return findEntityDefinitions(ast).flatMap((definition) => {
|
|
97
|
+
const cyclePath = findCyclePath(definition.name, graph);
|
|
98
|
+
if (!cyclePath) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return [
|
|
103
|
+
buildCircularReferenceDiagnostic(
|
|
104
|
+
definition.name,
|
|
105
|
+
cyclePath,
|
|
106
|
+
filePath,
|
|
107
|
+
offsetToLine(sourceCode, definition.start)
|
|
108
|
+
),
|
|
109
|
+
];
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Warns when entity references form a direct or transitive cycle.
|
|
115
|
+
*/
|
|
116
|
+
export const circularRefs: ProjectAwareWardenRule = {
|
|
117
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
118
|
+
const ast = parse(filePath, sourceCode);
|
|
119
|
+
if (!ast) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const graph = collectEntityReferenceTargetsByName(ast);
|
|
124
|
+
return checkCircularReferences(ast, sourceCode, filePath, graph);
|
|
125
|
+
},
|
|
126
|
+
checkWithContext(
|
|
127
|
+
sourceCode: string,
|
|
128
|
+
filePath: string,
|
|
129
|
+
context: ProjectContext
|
|
130
|
+
): readonly WardenDiagnostic[] {
|
|
131
|
+
const ast = parse(filePath, sourceCode);
|
|
132
|
+
if (!ast) {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const localGraph = collectEntityReferenceTargetsByName(
|
|
137
|
+
ast,
|
|
138
|
+
context.knownEntityIds
|
|
139
|
+
);
|
|
140
|
+
return checkCircularReferences(
|
|
141
|
+
ast,
|
|
142
|
+
sourceCode,
|
|
143
|
+
filePath,
|
|
144
|
+
mergeReferenceGraphs(localGraph, context.entityReferencesByName)
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
description: 'Warn when entity references form direct or transitive cycles.',
|
|
148
|
+
name: 'circular-refs',
|
|
149
|
+
severity: 'warn',
|
|
150
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { deriveTrailCliCommandProjection } from '@ontrails/core';
|
|
2
|
+
import type { CliCommandRoute, Topo } from '@ontrails/core';
|
|
3
|
+
import type { TopoGraph } from '@ontrails/topography';
|
|
4
|
+
|
|
5
|
+
import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
|
|
6
|
+
|
|
7
|
+
const RULE_NAME = 'cli-command-route-coherence';
|
|
8
|
+
const TOPO_FILE = '<topo>';
|
|
9
|
+
|
|
10
|
+
interface RouteClaim {
|
|
11
|
+
readonly kind: CliCommandRoute['kind'];
|
|
12
|
+
readonly path: readonly string[];
|
|
13
|
+
readonly source: CliCommandRoute['source'];
|
|
14
|
+
readonly trailId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const routeKey = (path: readonly string[]): string => path.join('\0');
|
|
18
|
+
|
|
19
|
+
const renderPath = (path: readonly string[]): string => path.join(' ');
|
|
20
|
+
|
|
21
|
+
const claimLabel = (claim: RouteClaim): string =>
|
|
22
|
+
`${claim.kind} route for trail "${claim.trailId}" (${claim.source})`;
|
|
23
|
+
|
|
24
|
+
const buildProjectionDiagnostic = (
|
|
25
|
+
trailId: string,
|
|
26
|
+
error: unknown
|
|
27
|
+
): WardenDiagnostic => ({
|
|
28
|
+
filePath: TOPO_FILE,
|
|
29
|
+
line: 1,
|
|
30
|
+
message: `CLI command route projection for trail "${trailId}" is invalid: ${
|
|
31
|
+
error instanceof Error ? error.message : String(error)
|
|
32
|
+
}`,
|
|
33
|
+
rule: RULE_NAME,
|
|
34
|
+
severity: 'error',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const buildCollisionDiagnostic = (
|
|
38
|
+
path: readonly string[],
|
|
39
|
+
claims: readonly RouteClaim[]
|
|
40
|
+
): WardenDiagnostic => ({
|
|
41
|
+
filePath: TOPO_FILE,
|
|
42
|
+
line: 1,
|
|
43
|
+
message: `CLI command route collision on "${renderPath(path)}": ${claims
|
|
44
|
+
.toSorted((a, b) => a.trailId.localeCompare(b.trailId))
|
|
45
|
+
.map(claimLabel)
|
|
46
|
+
.join(
|
|
47
|
+
', '
|
|
48
|
+
)}. Rename or remove one CLI alias so every accepted command path normalizes into exactly one trail contract.`,
|
|
49
|
+
rule: RULE_NAME,
|
|
50
|
+
severity: 'error',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const buildGraphTargetDiagnostic = (
|
|
54
|
+
entryId: string,
|
|
55
|
+
route: CliCommandRoute,
|
|
56
|
+
knownTrailIds: ReadonlySet<string>
|
|
57
|
+
): WardenDiagnostic => ({
|
|
58
|
+
filePath: TOPO_FILE,
|
|
59
|
+
line: 1,
|
|
60
|
+
message: knownTrailIds.has(route.target)
|
|
61
|
+
? `Serialized CLI command route "${renderPath(route.path)}" is stored under trail "${entryId}" but targets "${route.target}". Route facts must stay attached to their owning trail.`
|
|
62
|
+
: `Serialized CLI command route "${renderPath(route.path)}" targets unknown trail "${route.target}". Surface-owned aliases must target existing trail IDs.`,
|
|
63
|
+
rule: RULE_NAME,
|
|
64
|
+
severity: 'error',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const collectTopoClaims = (
|
|
68
|
+
topo: Topo
|
|
69
|
+
): {
|
|
70
|
+
readonly claims: readonly RouteClaim[];
|
|
71
|
+
readonly diagnostics: readonly WardenDiagnostic[];
|
|
72
|
+
} => {
|
|
73
|
+
const claims: RouteClaim[] = [];
|
|
74
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
75
|
+
|
|
76
|
+
for (const trail of topo.list()) {
|
|
77
|
+
try {
|
|
78
|
+
const projection = deriveTrailCliCommandProjection(trail);
|
|
79
|
+
for (const route of projection.routes) {
|
|
80
|
+
claims.push({
|
|
81
|
+
kind: route.kind,
|
|
82
|
+
path: route.path,
|
|
83
|
+
source: route.source,
|
|
84
|
+
trailId: trail.id,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
} catch (error: unknown) {
|
|
88
|
+
diagnostics.push(buildProjectionDiagnostic(trail.id, error));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { claims, diagnostics };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const collectGraphClaims = (
|
|
96
|
+
graph: TopoGraph | undefined
|
|
97
|
+
): readonly RouteClaim[] => {
|
|
98
|
+
if (graph === undefined) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const claims: RouteClaim[] = [];
|
|
103
|
+
for (const entry of graph.entries) {
|
|
104
|
+
if (entry.kind !== 'trail') {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
for (const route of entry.cli?.routes ?? []) {
|
|
108
|
+
claims.push({
|
|
109
|
+
kind: route.kind,
|
|
110
|
+
path: route.path,
|
|
111
|
+
source: route.source,
|
|
112
|
+
trailId: entry.id,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return claims;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const collectCollisionDiagnostics = (
|
|
120
|
+
claims: readonly RouteClaim[]
|
|
121
|
+
): readonly WardenDiagnostic[] => {
|
|
122
|
+
const claimsByRoute = new Map<string, RouteClaim[]>();
|
|
123
|
+
for (const claim of claims) {
|
|
124
|
+
const key = routeKey(claim.path);
|
|
125
|
+
const current = claimsByRoute.get(key) ?? [];
|
|
126
|
+
current.push(claim);
|
|
127
|
+
claimsByRoute.set(key, current);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return [...claimsByRoute.values()]
|
|
131
|
+
.filter((grouped) => grouped.length > 1)
|
|
132
|
+
.map((grouped) =>
|
|
133
|
+
buildCollisionDiagnostic(grouped[0]?.path ?? [], grouped)
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const collectGraphTargetDiagnostics = (
|
|
138
|
+
graph: TopoGraph | undefined,
|
|
139
|
+
knownTrailIds: ReadonlySet<string>
|
|
140
|
+
): readonly WardenDiagnostic[] => {
|
|
141
|
+
if (graph === undefined) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
146
|
+
for (const entry of graph.entries) {
|
|
147
|
+
if (entry.kind !== 'trail') {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (const route of entry.cli?.routes ?? []) {
|
|
151
|
+
if (route.target !== entry.id || !knownTrailIds.has(route.target)) {
|
|
152
|
+
diagnostics.push(
|
|
153
|
+
buildGraphTargetDiagnostic(entry.id, route, knownTrailIds)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return diagnostics;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const cliCommandRouteCoherence: TopoAwareWardenRule = {
|
|
162
|
+
checkTopo(topo, context) {
|
|
163
|
+
const { claims, diagnostics } = collectTopoClaims(topo);
|
|
164
|
+
const knownTrailIds = new Set(topo.trails.keys());
|
|
165
|
+
const routeClaims =
|
|
166
|
+
context?.graph === undefined ? claims : collectGraphClaims(context.graph);
|
|
167
|
+
return [
|
|
168
|
+
...diagnostics,
|
|
169
|
+
...collectCollisionDiagnostics(routeClaims),
|
|
170
|
+
...collectGraphTargetDiagnostics(context?.graph, knownTrailIds),
|
|
171
|
+
];
|
|
172
|
+
},
|
|
173
|
+
description:
|
|
174
|
+
'Ensure CLI command routes and aliases resolve to one coherent trail contract.',
|
|
175
|
+
name: RULE_NAME,
|
|
176
|
+
severity: 'error',
|
|
177
|
+
};
|