@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.22
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 +508 -6
- package/README.md +77 -26
- package/bin/warden.ts +50 -0
- package/package.json +27 -5
- package/src/adapter-check.ts +136 -0
- package/src/ast.ts +28 -0
- package/src/cli.ts +1374 -103
- package/src/command.ts +953 -0
- package/src/config.ts +184 -0
- package/src/draft.ts +22 -0
- package/src/drift.ts +106 -22
- package/src/fix.ts +120 -0
- package/src/formatters.ts +79 -9
- package/src/guide.ts +245 -0
- package/src/index.ts +206 -14
- package/src/project-context.ts +163 -0
- package/src/resolve.ts +530 -0
- package/src/rules/activation-orphan.ts +97 -0
- package/src/rules/ast.ts +3176 -85
- package/src/rules/circular-refs.ts +154 -0
- package/src/rules/composes-declarations.ts +704 -0
- package/src/rules/context-no-surface-types.ts +68 -8
- package/src/rules/contour-exists.ts +251 -0
- package/src/rules/contour-ids.ts +15 -0
- package/src/rules/dead-internal-trail.ts +154 -0
- package/src/rules/draft-file-marking.ts +160 -0
- package/src/rules/draft-visible-debt.ts +87 -0
- package/src/rules/error-mapping-completeness.ts +288 -0
- package/src/rules/example-valid.ts +401 -0
- package/src/rules/fires-declarations.ts +758 -0
- package/src/rules/implementation-returns-result.ts +1265 -95
- package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
- package/src/rules/incomplete-crud.ts +580 -0
- package/src/rules/index.ts +219 -18
- package/src/rules/intent-propagation.ts +127 -0
- package/src/rules/layer-field-name-drift.ts +96 -0
- package/src/rules/metadata.ts +654 -0
- package/src/rules/missing-reconcile.ts +98 -0
- package/src/rules/missing-visibility.ts +110 -0
- package/src/rules/no-destructured-compose.ts +192 -0
- package/src/rules/no-dev-permit-in-source.ts +99 -0
- package/src/rules/no-direct-implementation-call.ts +7 -7
- package/src/rules/no-legacy-layer-imports.ts +211 -0
- package/src/rules/no-native-error-result.ts +111 -0
- package/src/rules/no-redundant-result-error-wrap.ts +331 -0
- package/src/rules/no-retired-cross-vocabulary.ts +194 -0
- package/src/rules/no-sync-result-assumption.ts +1134 -99
- package/src/rules/no-throw-in-detour-recover.ts +225 -0
- package/src/rules/no-throw-in-implementation.ts +10 -9
- package/src/rules/no-top-level-surface.ts +389 -0
- package/src/rules/on-references-exist.ts +194 -0
- package/src/rules/orphaned-signal.ts +150 -0
- package/src/rules/owner-projection-parity.ts +146 -0
- package/src/rules/permit-governance.ts +25 -0
- package/src/rules/public-export-example-coverage.ts +553 -0
- package/src/rules/public-internal-deep-imports.ts +517 -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 +187 -0
- package/src/rules/reference-exists.ts +98 -0
- package/src/rules/registry-names.ts +145 -0
- package/src/rules/resolved-import-boundary.ts +146 -0
- package/src/rules/resource-declarations.ts +704 -0
- package/src/rules/resource-exists.ts +179 -0
- package/src/rules/resource-id-grammar.ts +65 -0
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/scan.ts +38 -25
- package/src/rules/scheduled-destroy-intent.ts +44 -0
- package/src/rules/signal-graph-coaching.ts +191 -0
- package/src/rules/specs.ts +9 -5
- package/src/rules/static-resource-accessor-preference.ts +657 -0
- package/src/rules/surface-facet-coherence.ts +370 -0
- package/src/rules/trail-versioning-source.ts +1094 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/types.ts +270 -6
- package/src/rules/unmaterialized-activation-source.ts +84 -0
- package/src/rules/unreachable-detour-shadowing.ts +344 -0
- package/src/rules/valid-describe-refs.ts +160 -32
- package/src/rules/valid-detour-contract.ts +78 -0
- package/src/rules/warden-export-symmetry.ts +533 -0
- package/src/rules/warden-rules-use-ast.ts +996 -0
- package/src/rules/webhook-route-collision.ts +243 -0
- package/src/trails/activation-orphan.trail.ts +84 -0
- package/src/trails/circular-refs.trail.ts +29 -0
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/context-no-surface-types.trail.ts +21 -0
- package/src/trails/contour-exists.trail.ts +21 -0
- package/src/trails/dead-internal-trail.trail.ts +26 -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/error-mapping-completeness.trail.ts +29 -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-blaze.trail.ts +31 -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 +78 -0
- package/src/trails/intent-propagation.trail.ts +30 -0
- package/src/trails/layer-field-name-drift.trail.ts +39 -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-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 +162 -0
- package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
- package/src/trails/schema.ts +194 -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-facet-coherence.trail.ts +25 -0
- package/src/trails/topo.ts +6 -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 +45 -0
- package/src/trails/webhook-route-collision.trail.ts +50 -0
- package/src/trails/wrap-rule.ts +213 -0
- package/src/workspaces.ts +238 -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 -221
- 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 -163
- 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 -231
- package/dist/rules/implementation-returns-result.js.map +0 -1
- package/dist/rules/index.d.ts +0 -22
- package/dist/rules/index.d.ts.map +0 -1
- package/dist/rules/index.js +0 -41
- 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 -46
- 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 -75
- 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 -188
- package/src/__tests__/valid-describe-refs.test.ts +0 -60
- package/src/rules/no-direct-impl-in-route.ts +0 -77
- 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
package/src/command.ts
ADDED
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
deriveCliFlagValueAliases,
|
|
8
|
+
findAppModule,
|
|
9
|
+
findAppModuleCandidates,
|
|
10
|
+
} from '@ontrails/cli';
|
|
11
|
+
import type {
|
|
12
|
+
CliFlagValueAlias,
|
|
13
|
+
CliFlagValueAliasDeclaration,
|
|
14
|
+
} from '@ontrails/cli';
|
|
15
|
+
import type { Topo } from '@ontrails/core';
|
|
16
|
+
import { AmbiguousError, NotFoundError } from '@ontrails/core';
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
WardenConfigInput,
|
|
20
|
+
WardenConfigLayer,
|
|
21
|
+
WardenDepth,
|
|
22
|
+
WardenDraftsMode,
|
|
23
|
+
WardenFailOn,
|
|
24
|
+
WardenFormat,
|
|
25
|
+
WardenLockMode,
|
|
26
|
+
} from './config.js';
|
|
27
|
+
import {
|
|
28
|
+
resolveWardenConfig,
|
|
29
|
+
wardenDepthValues,
|
|
30
|
+
wardenDraftsValues,
|
|
31
|
+
wardenFailOnValues,
|
|
32
|
+
wardenFormatValues,
|
|
33
|
+
wardenLockValues,
|
|
34
|
+
} from './config.js';
|
|
35
|
+
import type {
|
|
36
|
+
WardenReport,
|
|
37
|
+
WardenRunOptions,
|
|
38
|
+
WardenTopoTarget,
|
|
39
|
+
} from './cli.js';
|
|
40
|
+
import { runWarden } from './cli.js';
|
|
41
|
+
import {
|
|
42
|
+
formatGitHubAnnotations,
|
|
43
|
+
formatJson,
|
|
44
|
+
formatSummary,
|
|
45
|
+
} from './formatters.js';
|
|
46
|
+
import type { WardenDiagnostic, WardenSeverity } from './rules/types.js';
|
|
47
|
+
|
|
48
|
+
type EnvRecord = Record<string, string | undefined>;
|
|
49
|
+
|
|
50
|
+
interface MutableWardenConfigLayer {
|
|
51
|
+
apps?: string[] | undefined;
|
|
52
|
+
depth?: WardenDepth | undefined;
|
|
53
|
+
drafts?: WardenDraftsMode | undefined;
|
|
54
|
+
failOn?: WardenFailOn | undefined;
|
|
55
|
+
format?: WardenFormat | undefined;
|
|
56
|
+
lock?: WardenLockMode | undefined;
|
|
57
|
+
noLockMutation?: boolean | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CONFIG_CANDIDATES = [
|
|
61
|
+
'trails.config.ts',
|
|
62
|
+
'trails.config.mts',
|
|
63
|
+
'trails.config.js',
|
|
64
|
+
'trails.config.mjs',
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
const diagnostic = ({
|
|
68
|
+
filePath = '<warden-cli>',
|
|
69
|
+
message,
|
|
70
|
+
rule = 'warden-cli',
|
|
71
|
+
severity = 'error',
|
|
72
|
+
}: {
|
|
73
|
+
readonly filePath?: string | undefined;
|
|
74
|
+
readonly message: string;
|
|
75
|
+
readonly rule?: string | undefined;
|
|
76
|
+
readonly severity?: WardenSeverity | undefined;
|
|
77
|
+
}): WardenDiagnostic => ({
|
|
78
|
+
filePath,
|
|
79
|
+
line: 1,
|
|
80
|
+
message,
|
|
81
|
+
rule,
|
|
82
|
+
severity,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const errorMessage = (error: unknown): string =>
|
|
86
|
+
error instanceof Error ? error.message : String(error);
|
|
87
|
+
|
|
88
|
+
const cleanUndefined = <T extends Record<string, unknown>>(
|
|
89
|
+
value: T
|
|
90
|
+
): Partial<T> =>
|
|
91
|
+
Object.fromEntries(
|
|
92
|
+
Object.entries(value).filter(([, entry]) => entry !== undefined)
|
|
93
|
+
) as Partial<T>;
|
|
94
|
+
|
|
95
|
+
const splitApps = (value: string): readonly string[] =>
|
|
96
|
+
value
|
|
97
|
+
.split(',')
|
|
98
|
+
.map((entry) => entry.trim())
|
|
99
|
+
.filter((entry) => entry.length > 0);
|
|
100
|
+
|
|
101
|
+
const isAllowedValue = <T extends string>(
|
|
102
|
+
value: string,
|
|
103
|
+
allowed: readonly T[]
|
|
104
|
+
): value is T => allowed.includes(value as T);
|
|
105
|
+
|
|
106
|
+
interface EnumReadOptions<T extends string> {
|
|
107
|
+
readonly allowed: readonly T[];
|
|
108
|
+
readonly diagnostics: WardenDiagnostic[];
|
|
109
|
+
readonly flag: string;
|
|
110
|
+
readonly value: string | undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const readEnumValue = <T extends string>({
|
|
114
|
+
allowed,
|
|
115
|
+
diagnostics,
|
|
116
|
+
flag,
|
|
117
|
+
value,
|
|
118
|
+
}: EnumReadOptions<T>): T | undefined => {
|
|
119
|
+
if (value !== undefined && isAllowedValue(value, allowed)) {
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
diagnostics.push(
|
|
123
|
+
diagnostic({
|
|
124
|
+
message: `Invalid ${flag} value "${value ?? ''}". Expected one of: ${allowed.join(', ')}.`,
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
return undefined;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export interface ParsedWardenCommand {
|
|
131
|
+
readonly adapterCheck: boolean;
|
|
132
|
+
readonly ci: boolean;
|
|
133
|
+
readonly cli: WardenConfigLayer;
|
|
134
|
+
readonly configPath?: string | undefined;
|
|
135
|
+
readonly diagnostics: readonly WardenDiagnostic[];
|
|
136
|
+
readonly fix: boolean;
|
|
137
|
+
readonly prePush: boolean;
|
|
138
|
+
readonly rootDir?: string | undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const createEmptyParsedCommand = (message: string): ParsedWardenCommand => ({
|
|
142
|
+
adapterCheck: false,
|
|
143
|
+
ci: false,
|
|
144
|
+
cli: {},
|
|
145
|
+
diagnostics: [diagnostic({ message })],
|
|
146
|
+
fix: false,
|
|
147
|
+
prePush: false,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const tokenValue = (token: {
|
|
151
|
+
readonly value?: string | boolean | undefined;
|
|
152
|
+
}): string | undefined =>
|
|
153
|
+
typeof token.value === 'string' ? token.value : undefined;
|
|
154
|
+
|
|
155
|
+
interface CommandParserState {
|
|
156
|
+
adapterCheck?: boolean | undefined;
|
|
157
|
+
readonly apps: string[];
|
|
158
|
+
readonly diagnostics: WardenDiagnostic[];
|
|
159
|
+
readonly cli: MutableWardenConfigLayer;
|
|
160
|
+
configPath?: string | undefined;
|
|
161
|
+
fix?: boolean | undefined;
|
|
162
|
+
rootDir?: string | undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type AliasConfigKey = 'drafts' | 'format' | 'lock';
|
|
166
|
+
|
|
167
|
+
interface WardenAliasSpec {
|
|
168
|
+
readonly aliases: CliFlagValueAliasDeclaration;
|
|
169
|
+
readonly choices: readonly string[];
|
|
170
|
+
readonly configKey: AliasConfigKey;
|
|
171
|
+
readonly flagName: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface WardenValueAliasTarget {
|
|
175
|
+
readonly alias: CliFlagValueAlias;
|
|
176
|
+
readonly configKey: AliasConfigKey;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const wardenAliasSpecs = [
|
|
180
|
+
{
|
|
181
|
+
aliases: true,
|
|
182
|
+
choices: wardenFormatValues,
|
|
183
|
+
configKey: 'format',
|
|
184
|
+
flagName: 'format',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
aliases: {
|
|
188
|
+
cached: 'cached',
|
|
189
|
+
refresh: 'refresh',
|
|
190
|
+
skip: 'skip-lock',
|
|
191
|
+
},
|
|
192
|
+
choices: wardenLockValues,
|
|
193
|
+
configKey: 'lock',
|
|
194
|
+
flagName: 'lock',
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
aliases: {
|
|
198
|
+
exclude: 'exclude-drafts',
|
|
199
|
+
include: 'include-drafts',
|
|
200
|
+
only: 'only-drafts',
|
|
201
|
+
},
|
|
202
|
+
choices: wardenDraftsValues,
|
|
203
|
+
configKey: 'drafts',
|
|
204
|
+
flagName: 'drafts',
|
|
205
|
+
},
|
|
206
|
+
] satisfies readonly WardenAliasSpec[];
|
|
207
|
+
|
|
208
|
+
const wardenValueAliasTargets: readonly WardenValueAliasTarget[] =
|
|
209
|
+
wardenAliasSpecs.flatMap((spec) =>
|
|
210
|
+
(
|
|
211
|
+
deriveCliFlagValueAliases({
|
|
212
|
+
aliases: spec.aliases,
|
|
213
|
+
choices: spec.choices,
|
|
214
|
+
flagName: spec.flagName,
|
|
215
|
+
}) ?? []
|
|
216
|
+
).map((alias) => ({
|
|
217
|
+
alias,
|
|
218
|
+
configKey: spec.configKey,
|
|
219
|
+
}))
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const wardenValueAliasTargetByName = new Map(
|
|
223
|
+
wardenValueAliasTargets.map((target) => [target.alias.name, target])
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const valueAliasParseOptions = Object.fromEntries(
|
|
227
|
+
wardenValueAliasTargets.map((target) => [
|
|
228
|
+
target.alias.name,
|
|
229
|
+
{ type: 'boolean' as const },
|
|
230
|
+
])
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const parseTokens = (
|
|
234
|
+
args: readonly string[]
|
|
235
|
+
): ReturnType<typeof parseArgs> | { readonly error: string } => {
|
|
236
|
+
try {
|
|
237
|
+
return parseArgs({
|
|
238
|
+
allowPositionals: false,
|
|
239
|
+
args: [...args],
|
|
240
|
+
options: {
|
|
241
|
+
'adapter-check': { type: 'boolean' },
|
|
242
|
+
apps: { multiple: true, short: 'a', type: 'string' },
|
|
243
|
+
ci: { type: 'boolean' },
|
|
244
|
+
'config-path': { type: 'string' },
|
|
245
|
+
depth: { type: 'string' },
|
|
246
|
+
drafts: { type: 'string' },
|
|
247
|
+
'fail-on': { type: 'string' },
|
|
248
|
+
fix: { type: 'boolean' },
|
|
249
|
+
format: { type: 'string' },
|
|
250
|
+
lock: { type: 'string' },
|
|
251
|
+
'no-lock-mutation': { type: 'boolean' },
|
|
252
|
+
'pre-push': { type: 'boolean' },
|
|
253
|
+
'root-dir': { type: 'string' },
|
|
254
|
+
strict: { type: 'boolean' },
|
|
255
|
+
...valueAliasParseOptions,
|
|
256
|
+
},
|
|
257
|
+
strict: true,
|
|
258
|
+
tokens: true,
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return { error: errorMessage(error) };
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const isParseError = (
|
|
266
|
+
value: ReturnType<typeof parseArgs> | { readonly error: string }
|
|
267
|
+
): value is { readonly error: string } => 'error' in value;
|
|
268
|
+
|
|
269
|
+
const applyPresetToken = (
|
|
270
|
+
token: NonNullable<ReturnType<typeof parseArgs>['tokens']>[number],
|
|
271
|
+
cli: MutableWardenConfigLayer
|
|
272
|
+
): { readonly ci: boolean; readonly prePush: boolean } => {
|
|
273
|
+
if (token.kind !== 'option') {
|
|
274
|
+
return { ci: false, prePush: false };
|
|
275
|
+
}
|
|
276
|
+
if (token.name === 'pre-push') {
|
|
277
|
+
Object.assign(cli, {
|
|
278
|
+
depth: 'project',
|
|
279
|
+
failOn: 'error',
|
|
280
|
+
lock: 'cached',
|
|
281
|
+
} satisfies WardenConfigLayer);
|
|
282
|
+
return { ci: false, prePush: true };
|
|
283
|
+
}
|
|
284
|
+
if (token.name === 'ci') {
|
|
285
|
+
Object.assign(cli, {
|
|
286
|
+
depth: 'all',
|
|
287
|
+
failOn: 'error',
|
|
288
|
+
format: 'github',
|
|
289
|
+
lock: 'auto',
|
|
290
|
+
noLockMutation: true,
|
|
291
|
+
} satisfies WardenConfigLayer);
|
|
292
|
+
return { ci: true, prePush: false };
|
|
293
|
+
}
|
|
294
|
+
return { ci: false, prePush: false };
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const applyAliasOption = (name: string, state: CommandParserState): boolean => {
|
|
298
|
+
const target = wardenValueAliasTargetByName.get(name);
|
|
299
|
+
if (target === undefined) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
if (target.configKey === 'format') {
|
|
303
|
+
state.cli.format = target.alias.value as WardenFormat;
|
|
304
|
+
} else if (target.configKey === 'lock') {
|
|
305
|
+
state.cli.lock = target.alias.value as WardenLockMode;
|
|
306
|
+
} else {
|
|
307
|
+
state.cli.drafts = target.alias.value as WardenDraftsMode;
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const applyEnumOption = (
|
|
313
|
+
name: string,
|
|
314
|
+
value: string | undefined,
|
|
315
|
+
state: CommandParserState
|
|
316
|
+
): boolean => {
|
|
317
|
+
if (name === 'depth') {
|
|
318
|
+
state.cli.depth = readEnumValue<WardenDepth>({
|
|
319
|
+
allowed: wardenDepthValues,
|
|
320
|
+
diagnostics: state.diagnostics,
|
|
321
|
+
flag: '--depth',
|
|
322
|
+
value,
|
|
323
|
+
});
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
if (name === 'drafts') {
|
|
327
|
+
state.cli.drafts = readEnumValue<WardenDraftsMode>({
|
|
328
|
+
allowed: wardenDraftsValues,
|
|
329
|
+
diagnostics: state.diagnostics,
|
|
330
|
+
flag: '--drafts',
|
|
331
|
+
value,
|
|
332
|
+
});
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
if (name === 'fail-on') {
|
|
336
|
+
state.cli.failOn = readEnumValue<WardenFailOn>({
|
|
337
|
+
allowed: wardenFailOnValues,
|
|
338
|
+
diagnostics: state.diagnostics,
|
|
339
|
+
flag: '--fail-on',
|
|
340
|
+
value,
|
|
341
|
+
});
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
if (name === 'format') {
|
|
345
|
+
state.cli.format = readEnumValue<WardenFormat>({
|
|
346
|
+
allowed: wardenFormatValues,
|
|
347
|
+
diagnostics: state.diagnostics,
|
|
348
|
+
flag: '--format',
|
|
349
|
+
value,
|
|
350
|
+
});
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
if (name === 'lock') {
|
|
354
|
+
state.cli.lock = readEnumValue<WardenLockMode>({
|
|
355
|
+
allowed: wardenLockValues,
|
|
356
|
+
diagnostics: state.diagnostics,
|
|
357
|
+
flag: '--lock',
|
|
358
|
+
value,
|
|
359
|
+
});
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const applyCommandOption = (
|
|
366
|
+
token: NonNullable<ReturnType<typeof parseArgs>['tokens']>[number],
|
|
367
|
+
state: CommandParserState
|
|
368
|
+
): void => {
|
|
369
|
+
if (
|
|
370
|
+
token.kind !== 'option' ||
|
|
371
|
+
token.name === 'ci' ||
|
|
372
|
+
token.name === 'pre-push'
|
|
373
|
+
) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const value = tokenValue(token);
|
|
378
|
+
if (
|
|
379
|
+
applyAliasOption(token.name, state) ||
|
|
380
|
+
applyEnumOption(token.name, value, state)
|
|
381
|
+
) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (token.name === 'apps') {
|
|
386
|
+
if (value === undefined) {
|
|
387
|
+
state.diagnostics.push(
|
|
388
|
+
diagnostic({ message: '--apps requires a comma-delimited value.' })
|
|
389
|
+
);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
state.apps.push(...splitApps(value));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (token.name === 'adapter-check') {
|
|
396
|
+
state.adapterCheck = true;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (token.name === 'config-path') {
|
|
400
|
+
state.configPath = value;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (token.name === 'fix') {
|
|
404
|
+
state.fix = true;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (token.name === 'no-lock-mutation') {
|
|
408
|
+
state.cli.noLockMutation = true;
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (token.name === 'root-dir') {
|
|
412
|
+
state.rootDir = value;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (token.name === 'strict') {
|
|
416
|
+
state.cli.failOn = 'warning';
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
state.diagnostics.push(
|
|
421
|
+
diagnostic({ message: `Unsupported Warden option: --${token.name}` })
|
|
422
|
+
);
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
export const parseWardenCommandArgs = (
|
|
426
|
+
args: readonly string[]
|
|
427
|
+
): ParsedWardenCommand => {
|
|
428
|
+
const parsed = parseTokens(args);
|
|
429
|
+
if (isParseError(parsed)) {
|
|
430
|
+
return createEmptyParsedCommand(parsed.error);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const state: CommandParserState = {
|
|
434
|
+
apps: [],
|
|
435
|
+
cli: {},
|
|
436
|
+
diagnostics: [],
|
|
437
|
+
};
|
|
438
|
+
let ci = false;
|
|
439
|
+
let prePush = false;
|
|
440
|
+
|
|
441
|
+
for (const token of parsed.tokens ?? []) {
|
|
442
|
+
const preset = applyPresetToken(token, state.cli);
|
|
443
|
+
ci = ci || preset.ci;
|
|
444
|
+
prePush = prePush || preset.prePush;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
for (const token of parsed.tokens ?? []) {
|
|
448
|
+
applyCommandOption(token, state);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (state.apps.length > 0) {
|
|
452
|
+
state.cli.apps = state.apps;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
adapterCheck: state.adapterCheck ?? false,
|
|
457
|
+
ci,
|
|
458
|
+
cli: cleanUndefined(
|
|
459
|
+
state.cli as Record<string, unknown>
|
|
460
|
+
) as WardenConfigLayer,
|
|
461
|
+
configPath: state.configPath,
|
|
462
|
+
diagnostics: state.diagnostics,
|
|
463
|
+
fix: state.fix ?? false,
|
|
464
|
+
prePush,
|
|
465
|
+
rootDir: state.rootDir,
|
|
466
|
+
};
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
interface WardenConfigLoadResult {
|
|
470
|
+
readonly config?: WardenConfigInput | undefined;
|
|
471
|
+
readonly configPath?: string | undefined;
|
|
472
|
+
readonly diagnostics: readonly WardenDiagnostic[];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const findConfigPath = (
|
|
476
|
+
rootDir: string,
|
|
477
|
+
configPath: string | undefined
|
|
478
|
+
): WardenConfigLoadResult => {
|
|
479
|
+
if (configPath !== undefined) {
|
|
480
|
+
const resolvedPath = resolve(rootDir, configPath);
|
|
481
|
+
return existsSync(resolvedPath)
|
|
482
|
+
? { configPath: resolvedPath, diagnostics: [] }
|
|
483
|
+
: {
|
|
484
|
+
diagnostics: [
|
|
485
|
+
diagnostic({
|
|
486
|
+
filePath: resolvedPath,
|
|
487
|
+
message: `Warden config file not found: ${resolvedPath}`,
|
|
488
|
+
rule: 'warden-config',
|
|
489
|
+
}),
|
|
490
|
+
],
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const candidate = CONFIG_CANDIDATES.map((entry) =>
|
|
495
|
+
resolve(rootDir, entry)
|
|
496
|
+
).find((entry) => existsSync(entry));
|
|
497
|
+
return candidate === undefined
|
|
498
|
+
? { diagnostics: [] }
|
|
499
|
+
: { configPath: candidate, diagnostics: [] };
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
503
|
+
typeof value === 'object' && value !== null;
|
|
504
|
+
|
|
505
|
+
interface ResultLike {
|
|
506
|
+
readonly error?: unknown;
|
|
507
|
+
readonly value?: unknown;
|
|
508
|
+
isErr(): boolean;
|
|
509
|
+
isOk(): boolean;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const isResultLike = (value: unknown): value is ResultLike =>
|
|
513
|
+
isRecord(value) &&
|
|
514
|
+
typeof value['isOk'] === 'function' &&
|
|
515
|
+
typeof value['isErr'] === 'function';
|
|
516
|
+
|
|
517
|
+
interface ResolvableConfig {
|
|
518
|
+
resolve(options: {
|
|
519
|
+
readonly cwd: string;
|
|
520
|
+
readonly env: EnvRecord;
|
|
521
|
+
}): Promise<unknown>;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const isResolvableConfig = (value: unknown): value is ResolvableConfig =>
|
|
525
|
+
isRecord(value) && typeof value['resolve'] === 'function';
|
|
526
|
+
|
|
527
|
+
const extractWardenConfig = (value: unknown): WardenConfigInput | undefined =>
|
|
528
|
+
isRecord(value) && 'warden' in value
|
|
529
|
+
? (value['warden'] as WardenConfigInput)
|
|
530
|
+
: undefined;
|
|
531
|
+
|
|
532
|
+
const importConfigModule = async (
|
|
533
|
+
configPath: string
|
|
534
|
+
): Promise<Record<string, unknown>> => {
|
|
535
|
+
const url = pathToFileURL(configPath);
|
|
536
|
+
url.searchParams.set('t', Date.now().toString());
|
|
537
|
+
return (await import(url.href)) as Record<string, unknown>;
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
export const loadWardenConfig = async ({
|
|
541
|
+
configPath,
|
|
542
|
+
env = {},
|
|
543
|
+
rootDir,
|
|
544
|
+
}: {
|
|
545
|
+
readonly configPath?: string | undefined;
|
|
546
|
+
readonly env?: EnvRecord | undefined;
|
|
547
|
+
readonly rootDir: string;
|
|
548
|
+
}): Promise<WardenConfigLoadResult> => {
|
|
549
|
+
const located = findConfigPath(rootDir, configPath);
|
|
550
|
+
if (located.configPath === undefined) {
|
|
551
|
+
return located;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
const mod = await importConfigModule(located.configPath);
|
|
556
|
+
const exported = mod['default'] ?? mod;
|
|
557
|
+
if (isResolvableConfig(exported)) {
|
|
558
|
+
const resolved = await exported.resolve({ cwd: rootDir, env });
|
|
559
|
+
if (isResultLike(resolved)) {
|
|
560
|
+
if (resolved.isOk()) {
|
|
561
|
+
return {
|
|
562
|
+
config: extractWardenConfig(resolved.value),
|
|
563
|
+
configPath: located.configPath,
|
|
564
|
+
diagnostics: located.diagnostics,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
configPath: located.configPath,
|
|
569
|
+
diagnostics: [
|
|
570
|
+
...located.diagnostics,
|
|
571
|
+
diagnostic({
|
|
572
|
+
filePath: located.configPath,
|
|
573
|
+
message: `Failed to resolve Warden config: ${errorMessage(resolved.error)}`,
|
|
574
|
+
rule: 'warden-config',
|
|
575
|
+
}),
|
|
576
|
+
],
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
config: extractWardenConfig(resolved),
|
|
581
|
+
configPath: located.configPath,
|
|
582
|
+
diagnostics: located.diagnostics,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
config: extractWardenConfig(exported),
|
|
587
|
+
configPath: located.configPath,
|
|
588
|
+
diagnostics: located.diagnostics,
|
|
589
|
+
};
|
|
590
|
+
} catch (error) {
|
|
591
|
+
return {
|
|
592
|
+
configPath: located.configPath,
|
|
593
|
+
diagnostics: [
|
|
594
|
+
...located.diagnostics,
|
|
595
|
+
diagnostic({
|
|
596
|
+
filePath: located.configPath,
|
|
597
|
+
message: `Failed to load Warden config: ${errorMessage(error)}`,
|
|
598
|
+
rule: 'warden-config',
|
|
599
|
+
}),
|
|
600
|
+
],
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const isTopo = (value: unknown): value is Topo => {
|
|
606
|
+
if (!isRecord(value)) {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
return (
|
|
610
|
+
value['trails'] instanceof Map &&
|
|
611
|
+
value['signals'] instanceof Map &&
|
|
612
|
+
value['resources'] instanceof Map &&
|
|
613
|
+
value['contours'] instanceof Map &&
|
|
614
|
+
typeof value['get'] === 'function' &&
|
|
615
|
+
typeof value['list'] === 'function' &&
|
|
616
|
+
typeof value['name'] === 'string'
|
|
617
|
+
);
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const TOPO_EXPORT_KEYS = ['default', 'graph', 'app'] as const;
|
|
621
|
+
|
|
622
|
+
const extractTopo = (
|
|
623
|
+
modulePath: string,
|
|
624
|
+
loaded: Record<string, unknown>
|
|
625
|
+
): Topo => {
|
|
626
|
+
for (const key of TOPO_EXPORT_KEYS) {
|
|
627
|
+
const candidate = loaded[key];
|
|
628
|
+
if (isTopo(candidate)) {
|
|
629
|
+
return candidate;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
throw new Error(
|
|
634
|
+
`Could not find a Topo export in "${modulePath}". Expected a default, "graph", or "app" export created with topo().`
|
|
635
|
+
);
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const resolveFilesystemModulePath = (
|
|
639
|
+
rootDir: string,
|
|
640
|
+
modulePath: string
|
|
641
|
+
): string => {
|
|
642
|
+
const absolutePath = isAbsolute(modulePath)
|
|
643
|
+
? modulePath
|
|
644
|
+
: resolve(rootDir, modulePath);
|
|
645
|
+
if (!absolutePath.endsWith('.js') || existsSync(absolutePath)) {
|
|
646
|
+
return absolutePath;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const tsPath = absolutePath.replace(/\.js$/, '.ts');
|
|
650
|
+
return existsSync(tsPath) ? tsPath : absolutePath;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const resolveDiscoveredModulePath = (
|
|
654
|
+
rootDir: string,
|
|
655
|
+
explicit?: string | undefined
|
|
656
|
+
): string =>
|
|
657
|
+
resolveFilesystemModulePath(rootDir, findAppModule(rootDir, explicit));
|
|
658
|
+
|
|
659
|
+
const appCandidateMatches = (candidate: string, appName: string): boolean =>
|
|
660
|
+
candidate === appName ||
|
|
661
|
+
candidate === `apps/${appName}/src/app.ts` ||
|
|
662
|
+
candidate.startsWith(`apps/${appName}/`);
|
|
663
|
+
|
|
664
|
+
const resolveNamedAppModulePath = (
|
|
665
|
+
rootDir: string,
|
|
666
|
+
appName: string
|
|
667
|
+
): string => {
|
|
668
|
+
const matched = findAppModuleCandidates(rootDir).find((candidate) =>
|
|
669
|
+
appCandidateMatches(candidate, appName)
|
|
670
|
+
);
|
|
671
|
+
return matched === undefined
|
|
672
|
+
? resolveDiscoveredModulePath(rootDir, appName)
|
|
673
|
+
: resolveFilesystemModulePath(rootDir, matched);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const importTopoFromModulePath = async (modulePath: string): Promise<Topo> => {
|
|
677
|
+
const loaded = (await import(pathToFileURL(modulePath).href)) as Record<
|
|
678
|
+
string,
|
|
679
|
+
unknown
|
|
680
|
+
>;
|
|
681
|
+
return extractTopo(modulePath, loaded);
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const topoLoadDiagnostic = ({
|
|
685
|
+
filePath,
|
|
686
|
+
message,
|
|
687
|
+
severity,
|
|
688
|
+
}: {
|
|
689
|
+
readonly filePath: string;
|
|
690
|
+
readonly message: string;
|
|
691
|
+
readonly severity: WardenSeverity;
|
|
692
|
+
}): WardenDiagnostic =>
|
|
693
|
+
diagnostic({
|
|
694
|
+
filePath,
|
|
695
|
+
message,
|
|
696
|
+
rule: 'topo-load',
|
|
697
|
+
severity,
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
const WARDEN_TOPO_SELECTION_HINT =
|
|
701
|
+
'Set warden.apps in trails.config.ts or pass --apps NAME,NAME.';
|
|
702
|
+
|
|
703
|
+
const cleanDiscoveryMessage = (message: string): string =>
|
|
704
|
+
message
|
|
705
|
+
.replaceAll('\n\nUse --module to select one explicitly.', '')
|
|
706
|
+
.replaceAll(' Use --module to specify the path.', '')
|
|
707
|
+
.trim();
|
|
708
|
+
|
|
709
|
+
const ambiguousTopoDiagnostic = (
|
|
710
|
+
rootDir: string,
|
|
711
|
+
message: string,
|
|
712
|
+
strict: boolean
|
|
713
|
+
): WardenDiagnostic =>
|
|
714
|
+
topoLoadDiagnostic({
|
|
715
|
+
filePath: rootDir,
|
|
716
|
+
message: `Multiple Trails apps discovered; skipping topo-aware rules. ${WARDEN_TOPO_SELECTION_HINT} ${cleanDiscoveryMessage(message)}`,
|
|
717
|
+
severity: strict ? 'error' : 'warn',
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const missingTopoDiagnostic = (
|
|
721
|
+
rootDir: string,
|
|
722
|
+
message: string
|
|
723
|
+
): WardenDiagnostic =>
|
|
724
|
+
topoLoadDiagnostic({
|
|
725
|
+
filePath: rootDir,
|
|
726
|
+
message: `No Trails app could be loaded for topo-aware Warden checks. ${cleanDiscoveryMessage(message)} ${WARDEN_TOPO_SELECTION_HINT}`,
|
|
727
|
+
severity: 'error',
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
interface ResolveTopoTargetsOptions {
|
|
731
|
+
readonly apps?: readonly string[] | undefined;
|
|
732
|
+
readonly rootDir: string;
|
|
733
|
+
readonly strict: boolean;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
interface ResolvedTopoTargets {
|
|
737
|
+
readonly diagnostics: readonly WardenDiagnostic[];
|
|
738
|
+
readonly topos: readonly WardenTopoTarget[];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export const resolveWardenTopoTargets = async ({
|
|
742
|
+
apps,
|
|
743
|
+
rootDir,
|
|
744
|
+
strict,
|
|
745
|
+
}: ResolveTopoTargetsOptions): Promise<ResolvedTopoTargets> => {
|
|
746
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
747
|
+
const topos: WardenTopoTarget[] = [];
|
|
748
|
+
|
|
749
|
+
if (apps !== undefined && apps.length > 0) {
|
|
750
|
+
for (const appName of apps) {
|
|
751
|
+
try {
|
|
752
|
+
const modulePath = resolveNamedAppModulePath(rootDir, appName);
|
|
753
|
+
topos.push({
|
|
754
|
+
name: appName,
|
|
755
|
+
topo: await importTopoFromModulePath(modulePath),
|
|
756
|
+
});
|
|
757
|
+
} catch (error) {
|
|
758
|
+
diagnostics.push(
|
|
759
|
+
topoLoadDiagnostic({
|
|
760
|
+
filePath: rootDir,
|
|
761
|
+
message: `Failed to load Trails app "${appName}" for Warden checks: ${errorMessage(error)}`,
|
|
762
|
+
severity: 'error',
|
|
763
|
+
})
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return { diagnostics, topos };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
const modulePath = resolveDiscoveredModulePath(rootDir);
|
|
772
|
+
const topo = await importTopoFromModulePath(modulePath);
|
|
773
|
+
return {
|
|
774
|
+
diagnostics,
|
|
775
|
+
topos: [{ name: topo.name, topo }],
|
|
776
|
+
};
|
|
777
|
+
} catch (error) {
|
|
778
|
+
if (error instanceof NotFoundError) {
|
|
779
|
+
return {
|
|
780
|
+
diagnostics: strict
|
|
781
|
+
? [missingTopoDiagnostic(rootDir, error.message)]
|
|
782
|
+
: diagnostics,
|
|
783
|
+
topos,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
if (error instanceof AmbiguousError) {
|
|
787
|
+
return {
|
|
788
|
+
diagnostics: [ambiguousTopoDiagnostic(rootDir, error.message, strict)],
|
|
789
|
+
topos,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
diagnostics: [
|
|
794
|
+
topoLoadDiagnostic({
|
|
795
|
+
filePath: rootDir,
|
|
796
|
+
message: `Failed to load Trails app for Warden checks: ${errorMessage(error)}`,
|
|
797
|
+
severity: 'error',
|
|
798
|
+
}),
|
|
799
|
+
],
|
|
800
|
+
topos,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const effectiveConfigNeedsTopo = (depth: WardenDepth): boolean =>
|
|
806
|
+
depth === 'topo' || depth === 'all';
|
|
807
|
+
|
|
808
|
+
const buildRunOptions = ({
|
|
809
|
+
adapterCheck,
|
|
810
|
+
cli,
|
|
811
|
+
config,
|
|
812
|
+
env,
|
|
813
|
+
fix,
|
|
814
|
+
rootDir,
|
|
815
|
+
topos,
|
|
816
|
+
}: {
|
|
817
|
+
readonly adapterCheck: boolean;
|
|
818
|
+
readonly cli: WardenConfigLayer;
|
|
819
|
+
readonly config?: WardenConfigInput | undefined;
|
|
820
|
+
readonly env: EnvRecord;
|
|
821
|
+
readonly fix: boolean;
|
|
822
|
+
readonly rootDir: string;
|
|
823
|
+
readonly topos: readonly WardenTopoTarget[];
|
|
824
|
+
}): WardenRunOptions => ({
|
|
825
|
+
...cleanUndefined({
|
|
826
|
+
adapterCheck,
|
|
827
|
+
apps: cli.apps,
|
|
828
|
+
config,
|
|
829
|
+
depth: cli.depth,
|
|
830
|
+
drafts: cli.drafts,
|
|
831
|
+
failOn: cli.failOn,
|
|
832
|
+
fix,
|
|
833
|
+
format: cli.format,
|
|
834
|
+
lock: cli.lock,
|
|
835
|
+
noLockMutation: cli.noLockMutation,
|
|
836
|
+
rootDir,
|
|
837
|
+
topos,
|
|
838
|
+
}),
|
|
839
|
+
env,
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
const reportPassed = (report: WardenReport): boolean =>
|
|
843
|
+
report.errorCount === 0 &&
|
|
844
|
+
(report.effectiveConfig?.failOn !== 'warning' || report.warnCount === 0) &&
|
|
845
|
+
!(report.drift?.stale ?? false) &&
|
|
846
|
+
report.drift?.blockedReason === undefined;
|
|
847
|
+
|
|
848
|
+
const mergeDiagnosticsIntoReport = (
|
|
849
|
+
report: WardenReport,
|
|
850
|
+
diagnostics: readonly WardenDiagnostic[]
|
|
851
|
+
): WardenReport => {
|
|
852
|
+
if (diagnostics.length === 0) {
|
|
853
|
+
return report;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const mergedDiagnostics = [...diagnostics, ...report.diagnostics];
|
|
857
|
+
const mergedReport = {
|
|
858
|
+
...report,
|
|
859
|
+
diagnostics: mergedDiagnostics,
|
|
860
|
+
errorCount: mergedDiagnostics.filter((entry) => entry.severity === 'error')
|
|
861
|
+
.length,
|
|
862
|
+
warnCount: mergedDiagnostics.filter((entry) => entry.severity === 'warn')
|
|
863
|
+
.length,
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
return {
|
|
867
|
+
...mergedReport,
|
|
868
|
+
passed: reportPassed(mergedReport),
|
|
869
|
+
};
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
export const formatWardenCommandOutput = (report: WardenReport): string => {
|
|
873
|
+
switch (report.effectiveConfig?.format ?? 'summary') {
|
|
874
|
+
case 'github': {
|
|
875
|
+
return formatGitHubAnnotations(report);
|
|
876
|
+
}
|
|
877
|
+
case 'json': {
|
|
878
|
+
return formatJson(report);
|
|
879
|
+
}
|
|
880
|
+
case 'summary': {
|
|
881
|
+
return formatSummary(report);
|
|
882
|
+
}
|
|
883
|
+
default: {
|
|
884
|
+
return formatSummary(report);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
export interface WardenCommandResult {
|
|
890
|
+
readonly exitCode: 0 | 1;
|
|
891
|
+
readonly output: string;
|
|
892
|
+
readonly report: WardenReport;
|
|
893
|
+
readonly summary: string;
|
|
894
|
+
readonly writeStepSummary: boolean;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export interface RunWardenCommandOptions {
|
|
898
|
+
readonly args?: readonly string[] | undefined;
|
|
899
|
+
readonly cwd: string;
|
|
900
|
+
readonly env?: EnvRecord | undefined;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export const runWardenCommand = async ({
|
|
904
|
+
args = [],
|
|
905
|
+
cwd,
|
|
906
|
+
env = {},
|
|
907
|
+
}: RunWardenCommandOptions): Promise<WardenCommandResult> => {
|
|
908
|
+
const parsed = parseWardenCommandArgs(args);
|
|
909
|
+
const rootDir = resolve(cwd, parsed.rootDir ?? '.');
|
|
910
|
+
const loadedConfig = await loadWardenConfig({
|
|
911
|
+
configPath: parsed.configPath,
|
|
912
|
+
env,
|
|
913
|
+
rootDir,
|
|
914
|
+
});
|
|
915
|
+
const preflight = resolveWardenConfig({
|
|
916
|
+
cli: parsed.cli,
|
|
917
|
+
config: loadedConfig.config,
|
|
918
|
+
env,
|
|
919
|
+
});
|
|
920
|
+
const topoResolution = effectiveConfigNeedsTopo(
|
|
921
|
+
preflight.effectiveConfig.depth
|
|
922
|
+
)
|
|
923
|
+
? await resolveWardenTopoTargets({
|
|
924
|
+
apps: preflight.effectiveConfig.apps,
|
|
925
|
+
rootDir,
|
|
926
|
+
strict: parsed.ci,
|
|
927
|
+
})
|
|
928
|
+
: { diagnostics: [], topos: [] };
|
|
929
|
+
const report = await runWarden(
|
|
930
|
+
buildRunOptions({
|
|
931
|
+
adapterCheck: parsed.adapterCheck,
|
|
932
|
+
cli: parsed.cli,
|
|
933
|
+
config: loadedConfig.config,
|
|
934
|
+
env,
|
|
935
|
+
fix: parsed.fix,
|
|
936
|
+
rootDir,
|
|
937
|
+
topos: topoResolution.topos,
|
|
938
|
+
})
|
|
939
|
+
);
|
|
940
|
+
const finalReport = mergeDiagnosticsIntoReport(report, [
|
|
941
|
+
...parsed.diagnostics,
|
|
942
|
+
...loadedConfig.diagnostics,
|
|
943
|
+
...topoResolution.diagnostics,
|
|
944
|
+
]);
|
|
945
|
+
|
|
946
|
+
return {
|
|
947
|
+
exitCode: finalReport.passed ? 0 : 1,
|
|
948
|
+
output: formatWardenCommandOutput(finalReport),
|
|
949
|
+
report: finalReport,
|
|
950
|
+
summary: formatSummary(finalReport),
|
|
951
|
+
writeStepSummary: parsed.ci,
|
|
952
|
+
};
|
|
953
|
+
};
|