@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/config.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import type { WardenDiagnostic } from './rules/types.js';
|
|
4
|
+
|
|
5
|
+
export const wardenDepthValues = ['source', 'project', 'topo', 'all'] as const;
|
|
6
|
+
export const wardenFailOnValues = ['error', 'warning'] as const;
|
|
7
|
+
export const wardenFormatValues = ['summary', 'github', 'json'] as const;
|
|
8
|
+
export const wardenLockValues = ['auto', 'cached', 'refresh', 'skip'] as const;
|
|
9
|
+
export const wardenDraftsValues = ['include', 'exclude', 'only'] as const;
|
|
10
|
+
|
|
11
|
+
const appNameSchema = z.string().min(1);
|
|
12
|
+
|
|
13
|
+
const wardenConfigObjectSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
apps: z.array(appNameSchema).min(1).optional(),
|
|
16
|
+
depth: z.enum(wardenDepthValues).default('all'),
|
|
17
|
+
drafts: z.enum(wardenDraftsValues).default('include'),
|
|
18
|
+
failOn: z.enum(wardenFailOnValues).default('error'),
|
|
19
|
+
format: z.enum(wardenFormatValues).default('summary'),
|
|
20
|
+
lock: z.enum(wardenLockValues).default('auto'),
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
|
|
24
|
+
export const wardenConfigSchema = wardenConfigObjectSchema
|
|
25
|
+
.optional()
|
|
26
|
+
.transform((value) => wardenConfigObjectSchema.parse(value ?? {}));
|
|
27
|
+
|
|
28
|
+
export type WardenConfig = z.output<typeof wardenConfigSchema>;
|
|
29
|
+
export type WardenConfigInput = z.input<typeof wardenConfigSchema>;
|
|
30
|
+
export type WardenDepth = (typeof wardenDepthValues)[number];
|
|
31
|
+
export type WardenDraftsMode = (typeof wardenDraftsValues)[number];
|
|
32
|
+
export type WardenFailOn = (typeof wardenFailOnValues)[number];
|
|
33
|
+
export type WardenFormat = (typeof wardenFormatValues)[number];
|
|
34
|
+
export type WardenLockMode = (typeof wardenLockValues)[number];
|
|
35
|
+
|
|
36
|
+
export interface WardenConfigLayer extends Partial<WardenConfig> {
|
|
37
|
+
readonly noLockMutation?: boolean | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EffectiveWardenConfig extends WardenConfig {
|
|
41
|
+
readonly noLockMutation: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ResolveWardenConfigOptions {
|
|
45
|
+
readonly cli?: WardenConfigLayer | undefined;
|
|
46
|
+
readonly config?: WardenConfigInput | undefined;
|
|
47
|
+
readonly defaults?: Partial<WardenConfig> | undefined;
|
|
48
|
+
readonly env?: Record<string, string | undefined> | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface WardenConfigResolution {
|
|
52
|
+
readonly diagnostics: readonly WardenDiagnostic[];
|
|
53
|
+
readonly effectiveConfig: EffectiveWardenConfig;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const baseWardenConfig = (): WardenConfig => {
|
|
57
|
+
const omittedSection: unknown = undefined;
|
|
58
|
+
return wardenConfigSchema.parse(omittedSection);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const cleanUndefinedValues = <T extends Record<string, unknown>>(
|
|
62
|
+
value: T
|
|
63
|
+
): Partial<T> =>
|
|
64
|
+
Object.fromEntries(
|
|
65
|
+
Object.entries(value).filter(([, entry]) => entry !== undefined)
|
|
66
|
+
) as Partial<T>;
|
|
67
|
+
|
|
68
|
+
const splitApps = (value: string): readonly string[] =>
|
|
69
|
+
value
|
|
70
|
+
.split(',')
|
|
71
|
+
.map((entry) => entry.trim())
|
|
72
|
+
.filter((entry) => entry.length > 0);
|
|
73
|
+
|
|
74
|
+
const readEnvLayer = (
|
|
75
|
+
env: Record<string, string | undefined>
|
|
76
|
+
): Partial<WardenConfig> =>
|
|
77
|
+
cleanUndefinedValues({
|
|
78
|
+
apps: env['TRAILS_APPS'] ? splitApps(env['TRAILS_APPS']) : undefined,
|
|
79
|
+
depth: env['TRAILS_DEPTH'],
|
|
80
|
+
drafts: env['TRAILS_DRAFTS'],
|
|
81
|
+
failOn: env['TRAILS_FAIL_ON'],
|
|
82
|
+
format: env['TRAILS_FORMAT'],
|
|
83
|
+
lock: env['TRAILS_LOCK'],
|
|
84
|
+
}) as Partial<WardenConfig>;
|
|
85
|
+
|
|
86
|
+
const configDiagnostic = (message: string): WardenDiagnostic => ({
|
|
87
|
+
filePath: '<warden-config>',
|
|
88
|
+
line: 1,
|
|
89
|
+
message,
|
|
90
|
+
rule: 'warden-config',
|
|
91
|
+
severity: 'error',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const formatIssues = (error: z.ZodError): string =>
|
|
95
|
+
error.issues
|
|
96
|
+
.map((issue) => {
|
|
97
|
+
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
|
|
98
|
+
return `${path}: ${issue.message}`;
|
|
99
|
+
})
|
|
100
|
+
.join('; ');
|
|
101
|
+
|
|
102
|
+
const parseConfigLayer = (
|
|
103
|
+
label: string,
|
|
104
|
+
value: WardenConfigInput | undefined
|
|
105
|
+
): {
|
|
106
|
+
readonly data: Partial<WardenConfig>;
|
|
107
|
+
readonly diagnostics: readonly WardenDiagnostic[];
|
|
108
|
+
} => {
|
|
109
|
+
if (value === undefined) {
|
|
110
|
+
return { data: {}, diagnostics: [] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const parsed = wardenConfigSchema.safeParse(value);
|
|
114
|
+
if (parsed.success) {
|
|
115
|
+
if (typeof value !== 'object' || value === null) {
|
|
116
|
+
return { data: parsed.data, diagnostics: [] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
data: Object.fromEntries(
|
|
121
|
+
Object.keys(value).map((key) => [
|
|
122
|
+
key,
|
|
123
|
+
parsed.data[key as keyof WardenConfig],
|
|
124
|
+
])
|
|
125
|
+
) as Partial<WardenConfig>,
|
|
126
|
+
diagnostics: [],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
data: {},
|
|
132
|
+
diagnostics: [
|
|
133
|
+
configDiagnostic(
|
|
134
|
+
`Invalid ${label} Warden config: ${formatIssues(parsed.error)}`
|
|
135
|
+
),
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const resolveWardenConfig = ({
|
|
141
|
+
cli,
|
|
142
|
+
config,
|
|
143
|
+
defaults,
|
|
144
|
+
env = {},
|
|
145
|
+
}: ResolveWardenConfigOptions = {}): WardenConfigResolution => {
|
|
146
|
+
const { noLockMutation = false, ...cliConfig } = cli ?? {};
|
|
147
|
+
const defaultLayer = wardenConfigSchema.parse({
|
|
148
|
+
...baseWardenConfig(),
|
|
149
|
+
...defaults,
|
|
150
|
+
});
|
|
151
|
+
const configLayer = parseConfigLayer('file', config);
|
|
152
|
+
const envLayer = parseConfigLayer('environment', readEnvLayer(env));
|
|
153
|
+
const merged = {
|
|
154
|
+
...defaultLayer,
|
|
155
|
+
...configLayer.data,
|
|
156
|
+
...envLayer.data,
|
|
157
|
+
...cleanUndefinedValues(cliConfig),
|
|
158
|
+
};
|
|
159
|
+
const parsed = wardenConfigSchema.safeParse(merged);
|
|
160
|
+
const diagnostics = [...configLayer.diagnostics, ...envLayer.diagnostics];
|
|
161
|
+
|
|
162
|
+
if (!parsed.success) {
|
|
163
|
+
return {
|
|
164
|
+
diagnostics: [
|
|
165
|
+
...diagnostics,
|
|
166
|
+
configDiagnostic(
|
|
167
|
+
`Invalid effective Warden config: ${formatIssues(parsed.error)}`
|
|
168
|
+
),
|
|
169
|
+
],
|
|
170
|
+
effectiveConfig: {
|
|
171
|
+
...defaultLayer,
|
|
172
|
+
noLockMutation,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
diagnostics,
|
|
179
|
+
effectiveConfig: {
|
|
180
|
+
...parsed.data,
|
|
181
|
+
noLockMutation,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
};
|
package/src/draft.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export const DRAFT_FILE_PREFIX = '_draft.';
|
|
4
|
+
export const DRAFT_FILE_SEGMENT = '.draft.';
|
|
5
|
+
|
|
6
|
+
const DRAFT_TRAILING_SEGMENT = /\.draft(?=\.[^.]+$)/;
|
|
7
|
+
|
|
8
|
+
export const isDraftMarkedFile = (filePath: string): boolean => {
|
|
9
|
+
const fileName = basename(filePath);
|
|
10
|
+
return (
|
|
11
|
+
fileName.startsWith(DRAFT_FILE_PREFIX) ||
|
|
12
|
+
fileName.includes(DRAFT_FILE_SEGMENT)
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const stripDraftFileMarkers = (fileName: string): string => {
|
|
17
|
+
if (fileName.startsWith(DRAFT_FILE_PREFIX)) {
|
|
18
|
+
return fileName.slice(DRAFT_FILE_PREFIX.length);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return fileName.replace(DRAFT_TRAILING_SEGMENT, '');
|
|
22
|
+
};
|
package/src/drift.ts
CHANGED
|
@@ -1,32 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Topo lock drift detection.
|
|
3
3
|
*
|
|
4
|
-
* Compares the
|
|
5
|
-
*
|
|
6
|
-
* updating the
|
|
4
|
+
* Compares the `topo.lock` artifact hash listed in `trails.lock` against a
|
|
5
|
+
* freshly generated TopoGraph hash to detect when the trail topology has
|
|
6
|
+
* changed without updating the artifact family.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { existsSync, statSync } from 'node:fs';
|
|
10
|
+
|
|
9
11
|
import type { Topo } from '@ontrails/core';
|
|
10
12
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from '@ontrails/
|
|
13
|
+
deriveTrailsDir,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
} from '@ontrails/core';
|
|
17
|
+
import {
|
|
18
|
+
createTopoStore,
|
|
19
|
+
deriveTopoGraph,
|
|
20
|
+
deriveTopoGraphHash,
|
|
21
|
+
isTopoArtifactRegenerationError,
|
|
22
|
+
readLockManifest,
|
|
23
|
+
} from '@ontrails/topographer';
|
|
24
|
+
import type { LockManifest } from '@ontrails/topographer';
|
|
15
25
|
|
|
16
26
|
/**
|
|
17
|
-
* Result of a drift check comparing committed
|
|
27
|
+
* Result of a drift check comparing committed trails.lock against the current state.
|
|
18
28
|
*/
|
|
19
29
|
export interface DriftResult {
|
|
30
|
+
/** Why drift could not be computed for the established graph, when blocked. */
|
|
31
|
+
readonly blockedReason?: string | undefined;
|
|
20
32
|
/** Whether the committed lock is out of date */
|
|
21
33
|
readonly stale: boolean;
|
|
22
|
-
/** Hash from the committed
|
|
34
|
+
/** Hash from the committed trails.lock file, or null if not found */
|
|
23
35
|
readonly committedHash: string | null;
|
|
24
36
|
/** Hash computed from the current trail topology */
|
|
25
37
|
readonly currentHash: string;
|
|
26
38
|
}
|
|
27
39
|
|
|
40
|
+
interface BlockedLockRead {
|
|
41
|
+
readonly drift: DriftResult;
|
|
42
|
+
readonly kind: 'blocked-lock-read';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const blockedDrift = (reason: string): DriftResult => ({
|
|
46
|
+
blockedReason: reason,
|
|
47
|
+
committedHash: null,
|
|
48
|
+
currentHash: 'blocked',
|
|
49
|
+
stale: true,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const blockedLockRead = (reason: string): BlockedLockRead => ({
|
|
53
|
+
drift: blockedDrift(reason),
|
|
54
|
+
kind: 'blocked-lock-read',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const readCommittedLockManifest = async (
|
|
58
|
+
rootDir: string
|
|
59
|
+
): Promise<BlockedLockRead | LockManifest | null> => {
|
|
60
|
+
const trailsDir = deriveTrailsDir({ rootDir });
|
|
61
|
+
try {
|
|
62
|
+
return existsSync(rootDir) && statSync(rootDir).isDirectory()
|
|
63
|
+
? await readLockManifest({ dir: trailsDir })
|
|
64
|
+
: null;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (isTopoArtifactRegenerationError(error)) {
|
|
67
|
+
return blockedLockRead(error.message);
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const isBlockedLockRead = (
|
|
74
|
+
result: BlockedLockRead | LockManifest | null
|
|
75
|
+
): result is BlockedLockRead =>
|
|
76
|
+
result !== null && 'kind' in result && result.kind === 'blocked-lock-read';
|
|
77
|
+
|
|
28
78
|
/**
|
|
29
|
-
* Check whether the committed
|
|
79
|
+
* Check whether the committed trails.lock is stale compared to the current topology.
|
|
30
80
|
*
|
|
31
81
|
* When no topo is provided, returns a clean result (no drift detectable without runtime info).
|
|
32
82
|
*/
|
|
@@ -34,17 +84,51 @@ export const checkDrift = async (
|
|
|
34
84
|
rootDir: string,
|
|
35
85
|
topo?: Topo | undefined
|
|
36
86
|
): Promise<DriftResult> => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
87
|
+
try {
|
|
88
|
+
const lockManifest = await readCommittedLockManifest(rootDir);
|
|
89
|
+
if (isBlockedLockRead(lockManifest)) {
|
|
90
|
+
return lockManifest.drift;
|
|
91
|
+
}
|
|
92
|
+
const topoArtifact =
|
|
93
|
+
lockManifest?.artifacts.find(
|
|
94
|
+
(artifact) => artifact.role === 'topo' && artifact.path === 'topo.lock'
|
|
95
|
+
) ?? null;
|
|
96
|
+
if (lockManifest !== null && topoArtifact === null) {
|
|
97
|
+
return blockedDrift(
|
|
98
|
+
'trails.lock does not contain a topo.lock artifact. Regenerate with `trails compile`.'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const readStoredHash = (): string | undefined => {
|
|
102
|
+
try {
|
|
103
|
+
return createTopoStore({ rootDir }).exports.get()?.topoGraphHash;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof NotFoundError) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const currentHash =
|
|
112
|
+
topo === undefined
|
|
113
|
+
? (readStoredHash() ?? 'unknown')
|
|
114
|
+
: deriveTopoGraphHash(deriveTopoGraph(topo));
|
|
40
115
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
116
|
+
return {
|
|
117
|
+
committedHash: topoArtifact?.sha256 ?? null,
|
|
118
|
+
currentHash,
|
|
119
|
+
stale:
|
|
120
|
+
topoArtifact !== null &&
|
|
121
|
+
currentHash !== 'unknown' &&
|
|
122
|
+
topoArtifact.sha256 !== currentHash,
|
|
123
|
+
};
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (
|
|
126
|
+
!(error instanceof ValidationError) &&
|
|
127
|
+
!isTopoArtifactRegenerationError(error)
|
|
128
|
+
) {
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
44
131
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
currentHash,
|
|
48
|
-
stale: committedHash !== null && committedHash !== currentHash,
|
|
49
|
-
};
|
|
132
|
+
return blockedDrift(error.message);
|
|
133
|
+
}
|
|
50
134
|
};
|
package/src/fix.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe-fix execution for `warden --fix` (TRL-833).
|
|
3
|
+
*
|
|
4
|
+
* Consumes the structured {@link WardenFix} metadata a rule attaches to its
|
|
5
|
+
* diagnostics (TRL-831) and applies only the edits marked `safe`. Findings
|
|
6
|
+
* whose fix is `review`-required, or that carry no edits, are never applied —
|
|
7
|
+
* they stay reported so a human (or a downstream regrade) resolves them.
|
|
8
|
+
*
|
|
9
|
+
* The applicator is pure: it takes a file's source plus that file's
|
|
10
|
+
* diagnostics and returns the patched source plus which diagnostics were
|
|
11
|
+
* applied or skipped. The CLI layer owns reading and writing files.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { WardenDiagnostic, WardenFixEdit } from './rules/types.js';
|
|
15
|
+
|
|
16
|
+
/** A safe edit resolved from a diagnostic, ready to apply to a source string. */
|
|
17
|
+
interface ResolvedEdit {
|
|
18
|
+
readonly start: number;
|
|
19
|
+
readonly end: number;
|
|
20
|
+
readonly replacement: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Apply a set of edits to a source string, last-to-first.
|
|
25
|
+
*
|
|
26
|
+
* Edits are applied in descending start order so earlier offsets stay valid as
|
|
27
|
+
* later spans are spliced. Overlapping edits are a programming error in the
|
|
28
|
+
* rule that produced them; this throws rather than silently corrupt source.
|
|
29
|
+
*/
|
|
30
|
+
const applyEdits = (source: string, edits: readonly ResolvedEdit[]): string => {
|
|
31
|
+
for (const edit of edits) {
|
|
32
|
+
if (!Number.isSafeInteger(edit.start) || !Number.isSafeInteger(edit.end)) {
|
|
33
|
+
throw new RangeError(
|
|
34
|
+
`Fix edit [${String(edit.start)}, ${String(edit.end)}) must use safe integer offsets.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ordered = [...edits].toSorted(
|
|
40
|
+
(left, right) => right.start - left.start
|
|
41
|
+
);
|
|
42
|
+
let result = source;
|
|
43
|
+
let lastStart = Number.POSITIVE_INFINITY;
|
|
44
|
+
for (const edit of ordered) {
|
|
45
|
+
if (edit.start < 0 || edit.end > source.length || edit.start > edit.end) {
|
|
46
|
+
throw new RangeError(
|
|
47
|
+
`Fix edit [${edit.start}, ${edit.end}) is out of bounds for source of length ${source.length}.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (edit.end > lastStart) {
|
|
51
|
+
throw new RangeError(
|
|
52
|
+
`Fix edit [${edit.start}, ${edit.end}) overlaps a later edit starting at ${lastStart}.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
result =
|
|
56
|
+
result.slice(0, edit.start) + edit.replacement + result.slice(edit.end);
|
|
57
|
+
lastStart = edit.start;
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Whether a diagnostic carries an applicable safe fix with concrete edits. */
|
|
63
|
+
export const hasSafeFixEdits = (
|
|
64
|
+
diagnostic: WardenDiagnostic
|
|
65
|
+
): diagnostic is WardenDiagnostic & {
|
|
66
|
+
readonly fix: { readonly edits: readonly WardenFixEdit[] };
|
|
67
|
+
} =>
|
|
68
|
+
diagnostic.fix?.safety === 'safe' &&
|
|
69
|
+
diagnostic.fix.edits !== undefined &&
|
|
70
|
+
diagnostic.fix.edits.length > 0;
|
|
71
|
+
|
|
72
|
+
/** Result of applying safe fixes to a single file's source. */
|
|
73
|
+
export interface WardenFileFixResult {
|
|
74
|
+
/** Source after applying every safe edit; unchanged when none applied. */
|
|
75
|
+
readonly patched: string;
|
|
76
|
+
/** Whether any edit was applied (i.e. `patched` differs from input). */
|
|
77
|
+
readonly changed: boolean;
|
|
78
|
+
/** Diagnostics whose safe fix was applied. */
|
|
79
|
+
readonly applied: readonly WardenDiagnostic[];
|
|
80
|
+
/** Diagnostics left reported (review-required, or no safe edits). */
|
|
81
|
+
readonly skipped: readonly WardenDiagnostic[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Apply the safe fixes among a file's diagnostics to its source.
|
|
86
|
+
*
|
|
87
|
+
* Pure and filesystem-free. Only `safety: 'safe'` fixes with edits are applied;
|
|
88
|
+
* everything else is returned in `skipped`. Edits from all applicable
|
|
89
|
+
* diagnostics are pooled and applied last-to-first in one pass.
|
|
90
|
+
*/
|
|
91
|
+
export const applySafeFixesToSource = (
|
|
92
|
+
source: string,
|
|
93
|
+
diagnostics: readonly WardenDiagnostic[]
|
|
94
|
+
): WardenFileFixResult => {
|
|
95
|
+
const applied: WardenDiagnostic[] = [];
|
|
96
|
+
const skipped: WardenDiagnostic[] = [];
|
|
97
|
+
const edits: ResolvedEdit[] = [];
|
|
98
|
+
|
|
99
|
+
for (const diagnostic of diagnostics) {
|
|
100
|
+
if (hasSafeFixEdits(diagnostic)) {
|
|
101
|
+
applied.push(diagnostic);
|
|
102
|
+
for (const edit of diagnostic.fix.edits) {
|
|
103
|
+
edits.push({
|
|
104
|
+
end: edit.end,
|
|
105
|
+
replacement: edit.replacement,
|
|
106
|
+
start: edit.start,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
skipped.push(diagnostic);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (edits.length === 0) {
|
|
115
|
+
return { applied, changed: false, patched: source, skipped };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const patched = applyEdits(source, edits);
|
|
119
|
+
return { applied, changed: patched !== source, patched, skipped };
|
|
120
|
+
};
|
package/src/formatters.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { WardenReport } from './cli.js';
|
|
10
|
-
import type { WardenSeverity } from './rules/types.js';
|
|
10
|
+
import type { WardenGuidanceLink, WardenSeverity } from './rules/types.js';
|
|
11
11
|
|
|
12
12
|
/** Map warden severity to GitHub Actions annotation level. */
|
|
13
13
|
const ghLevel: Record<WardenSeverity, string> = {
|
|
@@ -19,7 +19,8 @@ const ghLevel: Record<WardenSeverity, string> = {
|
|
|
19
19
|
* Produce GitHub Actions workflow command annotations, one per diagnostic.
|
|
20
20
|
*
|
|
21
21
|
* Severity mapping: `error` to `::error`, `warn` to `::warning`.
|
|
22
|
-
* Drift staleness is emitted as a single
|
|
22
|
+
* Drift staleness or established-export blocking is emitted as a single
|
|
23
|
+
* `::error` annotation when detected.
|
|
23
24
|
*/
|
|
24
25
|
export const formatGitHubAnnotations = (report: WardenReport): string => {
|
|
25
26
|
const lines: string[] = [];
|
|
@@ -31,9 +32,11 @@ export const formatGitHubAnnotations = (report: WardenReport): string => {
|
|
|
31
32
|
);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
if (report.drift?.
|
|
35
|
+
if (report.drift?.blockedReason !== undefined) {
|
|
36
|
+
lines.push(`::error::drift: ${report.drift.blockedReason}`);
|
|
37
|
+
} else if (report.drift?.stale) {
|
|
35
38
|
lines.push(
|
|
36
|
-
'::error::drift:
|
|
39
|
+
'::error::drift: trails.lock is stale (regenerate with `trails compile`)'
|
|
37
40
|
);
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -57,6 +60,7 @@ export const formatJson = (report: WardenReport): string => {
|
|
|
57
60
|
{
|
|
58
61
|
diagnostics: report.diagnostics,
|
|
59
62
|
drift: report.drift,
|
|
63
|
+
fixes: report.fixes,
|
|
60
64
|
passed: report.passed,
|
|
61
65
|
summary,
|
|
62
66
|
},
|
|
@@ -65,9 +69,56 @@ export const formatJson = (report: WardenReport): string => {
|
|
|
65
69
|
);
|
|
66
70
|
};
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
const formatGuidanceLink = (link: WardenGuidanceLink): string => {
|
|
73
|
+
if (link.path !== undefined) {
|
|
74
|
+
return `[${link.label}](${link.path})`;
|
|
75
|
+
}
|
|
76
|
+
if (link.url !== undefined) {
|
|
77
|
+
return `[${link.label}](${link.url})`;
|
|
78
|
+
}
|
|
79
|
+
return link.label;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Format diagnostic guidance as indented markdown lines. */
|
|
83
|
+
const diagnosticGuidanceLines = (
|
|
84
|
+
d: WardenReport['diagnostics'][number]
|
|
85
|
+
): readonly string[] => {
|
|
86
|
+
const { guidance } = d;
|
|
87
|
+
if (guidance === undefined) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lines = [` - Next: ${guidance.summary}`];
|
|
92
|
+
if (guidance.steps !== undefined && guidance.steps.length > 0) {
|
|
93
|
+
lines.push(
|
|
94
|
+
` - Steps: ${guidance.steps
|
|
95
|
+
.map((step, index) => `${String(index + 1)}. ${step}`)
|
|
96
|
+
.join(' ')}`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (guidance.docs !== undefined && guidance.docs.length > 0) {
|
|
100
|
+
lines.push(` - Docs: ${guidance.docs.map(formatGuidanceLink).join(', ')}`);
|
|
101
|
+
}
|
|
102
|
+
if (guidance.commands !== undefined && guidance.commands.length > 0) {
|
|
103
|
+
lines.push(
|
|
104
|
+
` - Commands: ${guidance.commands.map((cmd) => `\`${cmd}\``).join(', ')}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (guidance.relatedRules !== undefined && guidance.relatedRules.length > 0) {
|
|
108
|
+
lines.push(
|
|
109
|
+
` - Related: ${guidance.relatedRules.map((rule) => `\`${rule}\``).join(', ')}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Format a diagnostic as markdown lines. */
|
|
116
|
+
const diagnosticLines = (
|
|
117
|
+
d: WardenReport['diagnostics'][number]
|
|
118
|
+
): readonly string[] => [
|
|
119
|
+
`- \`${d.filePath}:${String(d.line)}\` — ${d.rule}: ${d.message}`,
|
|
120
|
+
...diagnosticGuidanceLines(d),
|
|
121
|
+
];
|
|
71
122
|
|
|
72
123
|
/** Render a severity group as a headed markdown section, or empty array. */
|
|
73
124
|
const severitySection = (
|
|
@@ -77,18 +128,36 @@ const severitySection = (
|
|
|
77
128
|
if (diagnostics.length === 0) {
|
|
78
129
|
return [];
|
|
79
130
|
}
|
|
80
|
-
return ['', `### ${heading}`, ...diagnostics.
|
|
131
|
+
return ['', `### ${heading}`, ...diagnostics.flatMap(diagnosticLines)];
|
|
81
132
|
};
|
|
82
133
|
|
|
83
134
|
/** Render a drift section if stale, otherwise empty array. */
|
|
84
135
|
const driftSection = (drift: WardenReport['drift']): readonly string[] => {
|
|
136
|
+
if (drift?.blockedReason !== undefined) {
|
|
137
|
+
return [
|
|
138
|
+
'',
|
|
139
|
+
'### Drift',
|
|
140
|
+
`- established exports are blocked: ${drift.blockedReason}`,
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
|
|
85
144
|
if (!drift?.stale) {
|
|
86
145
|
return [];
|
|
87
146
|
}
|
|
88
147
|
return [
|
|
89
148
|
'',
|
|
90
149
|
'### Drift',
|
|
91
|
-
'-
|
|
150
|
+
'- trails.lock is stale (regenerate with `trails compile`)',
|
|
151
|
+
];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Render safe-fix counts when a fix pass was requested. */
|
|
155
|
+
const fixSummaryLine = (fixes: WardenReport['fixes']): readonly string[] => {
|
|
156
|
+
if (fixes === undefined) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
return [
|
|
160
|
+
`**Fixes:** ${String(fixes.applied)} applied, ${String(fixes.filesChanged)} files changed, ${String(fixes.skipped)} skipped`,
|
|
92
161
|
];
|
|
93
162
|
};
|
|
94
163
|
|
|
@@ -106,6 +175,7 @@ export const formatSummary = (report: WardenReport): string => {
|
|
|
106
175
|
'## Warden Report',
|
|
107
176
|
'',
|
|
108
177
|
`**Result: ${result}** | ${String(report.errorCount)} errors, ${String(report.warnCount)} warnings`,
|
|
178
|
+
...fixSummaryLine(report.fixes),
|
|
109
179
|
...severitySection('Errors', errors),
|
|
110
180
|
...severitySection('Warnings', warnings),
|
|
111
181
|
...driftSection(report.drift),
|