@ontrails/warden 1.0.0-beta.0
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/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +21 -0
- package/README.md +132 -0
- package/dist/cli.d.ts +46 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +221 -0
- package/dist/cli.js.map +1 -0
- package/dist/drift.d.ts +26 -0
- package/dist/drift.d.ts.map +1 -0
- package/dist/drift.js +27 -0
- package/dist/drift.js.map +1 -0
- package/dist/formatters.d.ts +29 -0
- package/dist/formatters.d.ts.map +1 -0
- package/dist/formatters.js +87 -0
- package/dist/formatters.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/rules/ast.d.ts +41 -0
- package/dist/rules/ast.d.ts.map +1 -0
- package/dist/rules/ast.js +163 -0
- package/dist/rules/ast.js.map +1 -0
- package/dist/rules/context-no-surface-types.d.ts +12 -0
- package/dist/rules/context-no-surface-types.d.ts.map +1 -0
- package/dist/rules/context-no-surface-types.js +96 -0
- package/dist/rules/context-no-surface-types.js.map +1 -0
- package/dist/rules/implementation-returns-result.d.ts +13 -0
- package/dist/rules/implementation-returns-result.d.ts.map +1 -0
- package/dist/rules/implementation-returns-result.js +231 -0
- package/dist/rules/implementation-returns-result.js.map +1 -0
- package/dist/rules/index.d.ts +22 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +41 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/no-direct-impl-in-route.d.ts +12 -0
- package/dist/rules/no-direct-impl-in-route.d.ts.map +1 -0
- package/dist/rules/no-direct-impl-in-route.js +46 -0
- package/dist/rules/no-direct-impl-in-route.js.map +1 -0
- package/dist/rules/no-direct-implementation-call.d.ts +12 -0
- package/dist/rules/no-direct-implementation-call.d.ts.map +1 -0
- package/dist/rules/no-direct-implementation-call.js +39 -0
- package/dist/rules/no-direct-implementation-call.js.map +1 -0
- package/dist/rules/no-sync-result-assumption.d.ts +6 -0
- package/dist/rules/no-sync-result-assumption.d.ts.map +1 -0
- package/dist/rules/no-sync-result-assumption.js +98 -0
- package/dist/rules/no-sync-result-assumption.js.map +1 -0
- package/dist/rules/no-throw-in-detour-target.d.ts +12 -0
- package/dist/rules/no-throw-in-detour-target.d.ts.map +1 -0
- package/dist/rules/no-throw-in-detour-target.js +87 -0
- package/dist/rules/no-throw-in-detour-target.js.map +1 -0
- package/dist/rules/no-throw-in-implementation.d.ts +9 -0
- package/dist/rules/no-throw-in-implementation.d.ts.map +1 -0
- package/dist/rules/no-throw-in-implementation.js +34 -0
- package/dist/rules/no-throw-in-implementation.js.map +1 -0
- package/dist/rules/prefer-schema-inference.d.ts +7 -0
- package/dist/rules/prefer-schema-inference.d.ts.map +1 -0
- package/dist/rules/prefer-schema-inference.js +86 -0
- package/dist/rules/prefer-schema-inference.js.map +1 -0
- package/dist/rules/scan.d.ts +8 -0
- package/dist/rules/scan.d.ts.map +1 -0
- package/dist/rules/scan.js +32 -0
- package/dist/rules/scan.js.map +1 -0
- package/dist/rules/specs.d.ts +29 -0
- package/dist/rules/specs.d.ts.map +1 -0
- package/dist/rules/specs.js +192 -0
- package/dist/rules/specs.js.map +1 -0
- package/dist/rules/structure.d.ts +13 -0
- package/dist/rules/structure.d.ts.map +1 -0
- package/dist/rules/structure.js +142 -0
- package/dist/rules/structure.js.map +1 -0
- package/dist/rules/types.d.ts +52 -0
- package/dist/rules/types.d.ts.map +1 -0
- package/dist/rules/types.js +2 -0
- package/dist/rules/types.js.map +1 -0
- package/dist/rules/valid-describe-refs.d.ts +7 -0
- package/dist/rules/valid-describe-refs.d.ts.map +1 -0
- package/dist/rules/valid-describe-refs.js +51 -0
- package/dist/rules/valid-describe-refs.js.map +1 -0
- package/dist/rules/valid-detour-refs.d.ts +6 -0
- package/dist/rules/valid-detour-refs.d.ts.map +1 -0
- package/dist/rules/valid-detour-refs.js +116 -0
- package/dist/rules/valid-detour-refs.js.map +1 -0
- package/package.json +25 -0
- package/src/__tests__/cli.test.ts +198 -0
- package/src/__tests__/drift.test.ts +74 -0
- package/src/__tests__/formatters.test.ts +157 -0
- package/src/__tests__/implementation-returns-result.test.ts +75 -0
- package/src/__tests__/no-direct-implementation-call.test.ts +83 -0
- package/src/__tests__/no-sync-result-assumption.test.ts +85 -0
- package/src/__tests__/no-throw-in-detour-target.test.ts +78 -0
- package/src/__tests__/prefer-schema-inference.test.ts +84 -0
- package/src/__tests__/rules.test.ts +188 -0
- package/src/__tests__/valid-describe-refs.test.ts +60 -0
- package/src/cli.ts +343 -0
- package/src/drift.ts +50 -0
- package/src/formatters.ts +113 -0
- package/src/index.ts +47 -0
- package/src/rules/ast.ts +217 -0
- package/src/rules/context-no-surface-types.ts +150 -0
- package/src/rules/implementation-returns-result.ts +343 -0
- package/src/rules/index.ts +54 -0
- package/src/rules/no-direct-impl-in-route.ts +77 -0
- package/src/rules/no-direct-implementation-call.ts +47 -0
- package/src/rules/no-sync-result-assumption.ts +156 -0
- package/src/rules/no-throw-in-detour-target.ts +150 -0
- package/src/rules/no-throw-in-implementation.ts +41 -0
- package/src/rules/prefer-schema-inference.ts +141 -0
- package/src/rules/scan.ts +46 -0
- package/src/rules/specs.ts +384 -0
- package/src/rules/structure.ts +234 -0
- package/src/rules/types.ts +62 -0
- package/src/rules/valid-describe-refs.ts +94 -0
- package/src/rules/valid-detour-refs.ts +187 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const TEST_FILE_PATTERN =
|
|
2
|
+
/(?:^|\/)__tests__(?:\/|$)|(?:\.test|\.spec)\.[cm]?[jt]sx?$/;
|
|
3
|
+
|
|
4
|
+
const FRAMEWORK_INTERNAL_SEGMENTS = [
|
|
5
|
+
'/packages/testing/',
|
|
6
|
+
'/packages/warden/',
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
const normalizeFilePath = (filePath: string): string =>
|
|
10
|
+
filePath.replaceAll('\\', '/');
|
|
11
|
+
|
|
12
|
+
const maskText = (text: string): string => text.replaceAll(/[^\n]/g, ' ');
|
|
13
|
+
|
|
14
|
+
const stripPattern = (sourceCode: string, pattern: RegExp): string =>
|
|
15
|
+
sourceCode.replaceAll(pattern, (match) => maskText(match));
|
|
16
|
+
|
|
17
|
+
export const isTestFile = (filePath: string): boolean =>
|
|
18
|
+
TEST_FILE_PATTERN.test(normalizeFilePath(filePath));
|
|
19
|
+
|
|
20
|
+
export const isFrameworkInternalFile = (filePath: string): boolean => {
|
|
21
|
+
const normalized = normalizeFilePath(filePath);
|
|
22
|
+
return FRAMEWORK_INTERNAL_SEGMENTS.some((segment) =>
|
|
23
|
+
normalized.includes(segment)
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Replace quoted content and comments with whitespace while preserving line
|
|
29
|
+
* breaks so simple line-based scanners do not match examples or messages.
|
|
30
|
+
*/
|
|
31
|
+
export const stripQuotedContent = (sourceCode: string): string => {
|
|
32
|
+
let sanitized = sourceCode;
|
|
33
|
+
const patterns = [
|
|
34
|
+
/\/\/[^\n]*/g,
|
|
35
|
+
/\/\*[\s\S]*?\*\//g,
|
|
36
|
+
/'[^'\\\n]*(?:\\.[^'\\\n]*)*'/g,
|
|
37
|
+
/"[^"\\\n]*(?:\\.[^"\\\n]*)*"/g,
|
|
38
|
+
/`[\s\S]*?`/g,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
for (const pattern of patterns) {
|
|
42
|
+
sanitized = stripPattern(sanitized, pattern);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return sanitized;
|
|
46
|
+
};
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import {
|
|
2
|
+
captureBalanced,
|
|
3
|
+
lineNumberAt,
|
|
4
|
+
splitTopLevelEntriesWithOffsets,
|
|
5
|
+
} from './structure.js';
|
|
6
|
+
import type { SplitEntry } from './structure.js';
|
|
7
|
+
|
|
8
|
+
export interface ParsedEntry {
|
|
9
|
+
readonly line: number;
|
|
10
|
+
readonly start: number;
|
|
11
|
+
readonly text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ObjectProperty extends ParsedEntry {
|
|
15
|
+
readonly key: string;
|
|
16
|
+
readonly value: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TrailLikeSpec {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly kind: 'hike' | 'trail';
|
|
22
|
+
readonly line: number;
|
|
23
|
+
readonly properties: ReadonlyMap<string, ObjectProperty>;
|
|
24
|
+
readonly specText: string;
|
|
25
|
+
readonly start: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SchemaFieldInfo {
|
|
29
|
+
readonly derivedLabel: string;
|
|
30
|
+
readonly options?: readonly string[] | undefined;
|
|
31
|
+
readonly required: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TRAIL_LIKE_PATTERN = /\b(trail|hike)\s*\(/g;
|
|
35
|
+
|
|
36
|
+
const PROPERTY_PATTERN =
|
|
37
|
+
/^(?:readonly\s+)?(?:(["'`])([^"'`]+)\1|([A-Za-z_$][\w$]*))\s*:\s*([\s\S]+)$/;
|
|
38
|
+
|
|
39
|
+
const OPTIONALISH_PATTERN =
|
|
40
|
+
/(?:^|[^\w])z\.(?:default|nullish|optional)\s*\(|\.(?:default|nullish|optional)\s*\(/;
|
|
41
|
+
|
|
42
|
+
const humanize = (str: string): string =>
|
|
43
|
+
str
|
|
44
|
+
.replaceAll(/([a-z])([A-Z])/g, '$1 $2')
|
|
45
|
+
.replaceAll(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
46
|
+
.replace(/^./, (ch) => ch.toUpperCase());
|
|
47
|
+
|
|
48
|
+
const trimWrapped = (
|
|
49
|
+
text: string,
|
|
50
|
+
open: '{' | '[',
|
|
51
|
+
close: '}' | ']'
|
|
52
|
+
): string | null => {
|
|
53
|
+
const trimmed = text.trim();
|
|
54
|
+
if (!trimmed.startsWith(open) || !trimmed.endsWith(close)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return trimmed.slice(1, -1);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const firstEntry = (text: string): SplitEntry | null => {
|
|
62
|
+
const [entry] = splitTopLevelEntriesWithOffsets(text);
|
|
63
|
+
return entry ?? null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const createParsedEntry = (
|
|
67
|
+
sourceCode: string,
|
|
68
|
+
start: number,
|
|
69
|
+
text: string
|
|
70
|
+
): ParsedEntry => ({
|
|
71
|
+
line: lineNumberAt(sourceCode, start),
|
|
72
|
+
start,
|
|
73
|
+
text,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const createObjectProperty = (
|
|
77
|
+
entry: SplitEntry,
|
|
78
|
+
key: string,
|
|
79
|
+
value: string,
|
|
80
|
+
objectStart: number,
|
|
81
|
+
objectOffset: number,
|
|
82
|
+
sourceCode: string
|
|
83
|
+
): ObjectProperty => {
|
|
84
|
+
const start = objectStart + objectOffset + 1 + entry.start;
|
|
85
|
+
return {
|
|
86
|
+
key,
|
|
87
|
+
line: lineNumberAt(sourceCode, start),
|
|
88
|
+
start,
|
|
89
|
+
text: entry.text,
|
|
90
|
+
value,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const parsePropertyEntry = (
|
|
95
|
+
entry: SplitEntry,
|
|
96
|
+
objectStart: number,
|
|
97
|
+
objectOffset: number,
|
|
98
|
+
sourceCode: string
|
|
99
|
+
): ObjectProperty | null => {
|
|
100
|
+
const propertyMatch = entry.text.match(PROPERTY_PATTERN);
|
|
101
|
+
const key = propertyMatch?.[2] ?? propertyMatch?.[3];
|
|
102
|
+
const value = propertyMatch?.[4]?.trim();
|
|
103
|
+
if (!key || !value) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return createObjectProperty(
|
|
108
|
+
entry,
|
|
109
|
+
key,
|
|
110
|
+
value,
|
|
111
|
+
objectStart,
|
|
112
|
+
objectOffset,
|
|
113
|
+
sourceCode
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const isDefined = <T>(value: T | null): value is T => value !== null;
|
|
118
|
+
|
|
119
|
+
export const parseStringLiteral = (text: string): string | null => {
|
|
120
|
+
const trimmed = text.trim();
|
|
121
|
+
const [quote = ''] = trimmed;
|
|
122
|
+
const closer = quote === "'" || quote === '"' || quote === '`' ? quote : null;
|
|
123
|
+
if (closer === null || trimmed.at(-1) !== closer) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const value = trimmed.slice(1, -1);
|
|
128
|
+
if (quote === '`' && value.includes('${')) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return value;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const parseArrayEntries = (
|
|
136
|
+
arrayText: string,
|
|
137
|
+
arrayStart: number,
|
|
138
|
+
sourceCode: string
|
|
139
|
+
): readonly ParsedEntry[] => {
|
|
140
|
+
const inner = trimWrapped(arrayText, '[', ']');
|
|
141
|
+
if (inner === null) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const arrayOffset = arrayText.indexOf('[');
|
|
146
|
+
return splitTopLevelEntriesWithOffsets(inner).map((entry) =>
|
|
147
|
+
createParsedEntry(
|
|
148
|
+
sourceCode,
|
|
149
|
+
arrayStart + arrayOffset + 1 + entry.start,
|
|
150
|
+
entry.text
|
|
151
|
+
)
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const parseObjectProperties = (
|
|
156
|
+
objectText: string,
|
|
157
|
+
objectStart: number,
|
|
158
|
+
sourceCode: string
|
|
159
|
+
): ReadonlyMap<string, ObjectProperty> => {
|
|
160
|
+
const inner = trimWrapped(objectText, '{', '}');
|
|
161
|
+
if (inner === null) {
|
|
162
|
+
return new Map();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const objectOffset = objectText.indexOf('{');
|
|
166
|
+
const properties = splitTopLevelEntriesWithOffsets(inner)
|
|
167
|
+
.map((entry) =>
|
|
168
|
+
parsePropertyEntry(entry, objectStart, objectOffset, sourceCode)
|
|
169
|
+
)
|
|
170
|
+
.filter(isDefined);
|
|
171
|
+
|
|
172
|
+
return new Map(properties.map((property) => [property.key, property]));
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const resolveSpecTarget = (
|
|
176
|
+
args: readonly SplitEntry[]
|
|
177
|
+
): { id: string | null; specArg: SplitEntry } | null => {
|
|
178
|
+
const [firstArg, secondArg] = args;
|
|
179
|
+
if (!firstArg) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const id = parseStringLiteral(firstArg.text);
|
|
184
|
+
return {
|
|
185
|
+
id,
|
|
186
|
+
specArg: id === null ? firstArg : (secondArg ?? firstArg),
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const parseCallArguments = (
|
|
191
|
+
sourceCode: string,
|
|
192
|
+
callStart: number
|
|
193
|
+
): { argsStart: number; argsText: string } | null => {
|
|
194
|
+
const openParen = sourceCode.indexOf('(', callStart);
|
|
195
|
+
const call = openParen === -1 ? null : captureBalanced(sourceCode, openParen);
|
|
196
|
+
if (call === null || openParen === -1) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
argsStart: openParen + 1,
|
|
202
|
+
argsText: call.text.slice(1, -1),
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const isSpecObject = (entry: SplitEntry | undefined): entry is SplitEntry =>
|
|
207
|
+
entry !== undefined && entry.text.trim().startsWith('{');
|
|
208
|
+
|
|
209
|
+
const resolveSpecId = (
|
|
210
|
+
resolved: { id: string | null; specArg: SplitEntry },
|
|
211
|
+
properties: ReadonlyMap<string, ObjectProperty>
|
|
212
|
+
): string | null =>
|
|
213
|
+
resolved.id ?? parseStringLiteral(properties.get('id')?.value ?? '');
|
|
214
|
+
|
|
215
|
+
const buildTrailLikeSpec = (
|
|
216
|
+
sourceCode: string,
|
|
217
|
+
kind: 'hike' | 'trail',
|
|
218
|
+
specArg: SplitEntry,
|
|
219
|
+
specStart: number,
|
|
220
|
+
id: string,
|
|
221
|
+
properties: ReadonlyMap<string, ObjectProperty>
|
|
222
|
+
): TrailLikeSpec => ({
|
|
223
|
+
id,
|
|
224
|
+
kind,
|
|
225
|
+
line: lineNumberAt(sourceCode, specStart),
|
|
226
|
+
properties,
|
|
227
|
+
specText: specArg.text,
|
|
228
|
+
start: specStart,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const parseResolvedSpec = (
|
|
232
|
+
sourceCode: string,
|
|
233
|
+
resolved: { id: string | null; specArg: SplitEntry },
|
|
234
|
+
call: { argsStart: number; argsText: string }
|
|
235
|
+
): {
|
|
236
|
+
id: string;
|
|
237
|
+
properties: ReadonlyMap<string, ObjectProperty>;
|
|
238
|
+
specStart: number;
|
|
239
|
+
} | null => {
|
|
240
|
+
const specStart = call.argsStart + resolved.specArg.start;
|
|
241
|
+
const properties = parseObjectProperties(
|
|
242
|
+
resolved.specArg.text,
|
|
243
|
+
specStart,
|
|
244
|
+
sourceCode
|
|
245
|
+
);
|
|
246
|
+
const id = resolveSpecId(resolved, properties);
|
|
247
|
+
return id === null ? null : { id, properties, specStart };
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const resolveTrailLikeSpec = (
|
|
251
|
+
sourceCode: string,
|
|
252
|
+
callStart: number
|
|
253
|
+
): {
|
|
254
|
+
parsed: {
|
|
255
|
+
id: string;
|
|
256
|
+
properties: ReadonlyMap<string, ObjectProperty>;
|
|
257
|
+
specStart: number;
|
|
258
|
+
};
|
|
259
|
+
resolved: { id: string | null; specArg: SplitEntry };
|
|
260
|
+
} | null => {
|
|
261
|
+
const call = parseCallArguments(sourceCode, callStart);
|
|
262
|
+
if (call === null) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const resolved = resolveSpecTarget(
|
|
267
|
+
splitTopLevelEntriesWithOffsets(call.argsText)
|
|
268
|
+
);
|
|
269
|
+
if (!resolved || !isSpecObject(resolved.specArg)) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const parsed = parseResolvedSpec(sourceCode, resolved, call);
|
|
274
|
+
return parsed === null ? null : { parsed, resolved };
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const parseTrailLikeMatch = (
|
|
278
|
+
sourceCode: string,
|
|
279
|
+
kind: 'hike' | 'trail',
|
|
280
|
+
callStart: number
|
|
281
|
+
): TrailLikeSpec | null => {
|
|
282
|
+
const resolved = resolveTrailLikeSpec(sourceCode, callStart);
|
|
283
|
+
if (resolved === null) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return buildTrailLikeSpec(
|
|
288
|
+
sourceCode,
|
|
289
|
+
kind,
|
|
290
|
+
resolved.resolved.specArg,
|
|
291
|
+
resolved.parsed.specStart,
|
|
292
|
+
resolved.parsed.id,
|
|
293
|
+
resolved.parsed.properties
|
|
294
|
+
);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const findCallArguments = (
|
|
298
|
+
sourceText: string,
|
|
299
|
+
pattern: RegExp
|
|
300
|
+
): string | null => {
|
|
301
|
+
const index = pattern.exec(sourceText)?.index;
|
|
302
|
+
if (index === undefined) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return parseCallArguments(sourceText, index)?.argsText ?? null;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const parseStringArrayLiteral = (
|
|
310
|
+
arrayText: string
|
|
311
|
+
): readonly string[] | null => {
|
|
312
|
+
const inner = trimWrapped(arrayText, '[', ']');
|
|
313
|
+
if (inner === null) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const values = splitTopLevelEntriesWithOffsets(inner).map((entry) =>
|
|
318
|
+
parseStringLiteral(entry.text)
|
|
319
|
+
);
|
|
320
|
+
const strings = values.filter((value): value is string => value !== null);
|
|
321
|
+
return strings.length === values.length ? strings : null;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const parseDescribeLabel = (fieldText: string): string | undefined => {
|
|
325
|
+
const args = findCallArguments(fieldText, /\.describe\s*\(/);
|
|
326
|
+
return args === null ? undefined : (parseStringLiteral(args) ?? undefined);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const parseEnumValues = (fieldText: string): readonly string[] | undefined => {
|
|
330
|
+
const args = findCallArguments(fieldText, /\bz\.enum\s*\(/);
|
|
331
|
+
const entry = args === null ? null : firstEntry(args);
|
|
332
|
+
return entry ? (parseStringArrayLiteral(entry.text) ?? undefined) : undefined;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const toSchemaFieldInfo = (
|
|
336
|
+
key: string,
|
|
337
|
+
property: ObjectProperty
|
|
338
|
+
): SchemaFieldInfo => ({
|
|
339
|
+
derivedLabel: parseDescribeLabel(property.value) ?? humanize(key),
|
|
340
|
+
options: parseEnumValues(property.value),
|
|
341
|
+
required: !OPTIONALISH_PATTERN.test(property.value),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
export const findTrailLikeSpecs = (
|
|
345
|
+
sourceCode: string
|
|
346
|
+
): readonly TrailLikeSpec[] => {
|
|
347
|
+
const specs: TrailLikeSpec[] = [];
|
|
348
|
+
|
|
349
|
+
for (const match of sourceCode.matchAll(TRAIL_LIKE_PATTERN)) {
|
|
350
|
+
const callStart = match.index;
|
|
351
|
+
if (callStart === undefined) {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const kind = match[1] === 'hike' ? 'hike' : 'trail';
|
|
356
|
+
const spec = parseTrailLikeMatch(sourceCode, kind, callStart);
|
|
357
|
+
if (spec !== null) {
|
|
358
|
+
specs.push(spec);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return specs;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
export const collectTrailIds = (sourceCode: string): ReadonlySet<string> =>
|
|
366
|
+
new Set(findTrailLikeSpecs(sourceCode).map((spec) => spec.id));
|
|
367
|
+
|
|
368
|
+
export const parseZodObjectShape = (
|
|
369
|
+
schemaText: string
|
|
370
|
+
): ReadonlyMap<string, SchemaFieldInfo> => {
|
|
371
|
+
const args = findCallArguments(schemaText, /\bz\.object\s*\(/);
|
|
372
|
+
const shapeEntry = args === null ? null : firstEntry(args);
|
|
373
|
+
if (!shapeEntry || !shapeEntry.text.trim().startsWith('{')) {
|
|
374
|
+
return new Map();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const shape = parseObjectProperties(shapeEntry.text, 0, shapeEntry.text);
|
|
378
|
+
return new Map(
|
|
379
|
+
[...shape.entries()].map(([key, property]) => [
|
|
380
|
+
key,
|
|
381
|
+
toSchemaFieldInfo(key, property),
|
|
382
|
+
])
|
|
383
|
+
);
|
|
384
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
type QuoteMode = '"' | "'" | '`' | null;
|
|
2
|
+
|
|
3
|
+
interface ScanState {
|
|
4
|
+
braceDepth: number;
|
|
5
|
+
bracketDepth: number;
|
|
6
|
+
escaped: boolean;
|
|
7
|
+
parenDepth: number;
|
|
8
|
+
quoteMode: QuoteMode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type DepthKey = 'braceDepth' | 'bracketDepth' | 'parenDepth';
|
|
12
|
+
|
|
13
|
+
interface BalancedSegment {
|
|
14
|
+
readonly end: number;
|
|
15
|
+
readonly text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SplitEntry {
|
|
19
|
+
readonly start: number;
|
|
20
|
+
readonly text: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const createState = (): ScanState => ({
|
|
24
|
+
braceDepth: 0,
|
|
25
|
+
bracketDepth: 0,
|
|
26
|
+
escaped: false,
|
|
27
|
+
parenDepth: 0,
|
|
28
|
+
quoteMode: null,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const enterQuote = (ch: string): QuoteMode => {
|
|
32
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
33
|
+
return ch;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const clearEscape = (state: ScanState): boolean => {
|
|
39
|
+
if (!state.escaped) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
state.escaped = false;
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const beginEscape = (state: ScanState, ch: string): boolean => {
|
|
48
|
+
if (ch !== '\\') {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
state.escaped = true;
|
|
53
|
+
return true;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const closeQuote = (state: ScanState, ch: string): void => {
|
|
57
|
+
if (ch === state.quoteMode) {
|
|
58
|
+
state.quoteMode = null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const updateQuotedState = (state: ScanState, ch: string): boolean => {
|
|
63
|
+
if (state.quoteMode === null) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (clearEscape(state)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (beginEscape(state, ch)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
closeQuote(state, ch);
|
|
76
|
+
return true;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const STRUCTURAL_DELTAS = {
|
|
80
|
+
'(': ['parenDepth', 1],
|
|
81
|
+
')': ['parenDepth', -1],
|
|
82
|
+
'[': ['bracketDepth', 1],
|
|
83
|
+
']': ['bracketDepth', -1],
|
|
84
|
+
'{': ['braceDepth', 1],
|
|
85
|
+
'}': ['braceDepth', -1],
|
|
86
|
+
} as const satisfies Record<string, readonly [DepthKey, number]>;
|
|
87
|
+
|
|
88
|
+
const updateStructuralDepth = (state: ScanState, ch: string): void => {
|
|
89
|
+
if (!(ch in STRUCTURAL_DELTAS)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const delta = STRUCTURAL_DELTAS[ch as keyof typeof STRUCTURAL_DELTAS];
|
|
94
|
+
const [key, amount] = delta;
|
|
95
|
+
state[key] += amount;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const isTopLevel = (state: ScanState): boolean =>
|
|
99
|
+
state.braceDepth === 0 &&
|
|
100
|
+
state.bracketDepth === 0 &&
|
|
101
|
+
state.parenDepth === 0 &&
|
|
102
|
+
state.quoteMode === null;
|
|
103
|
+
|
|
104
|
+
const scanCharacter = (state: ScanState, ch: string): void => {
|
|
105
|
+
if (updateQuotedState(state, ch)) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const nextQuoteMode = enterQuote(ch);
|
|
110
|
+
if (nextQuoteMode !== null) {
|
|
111
|
+
state.quoteMode = nextQuoteMode;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
updateStructuralDepth(state, ch);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const isBalancedOpener = (ch: string | undefined): boolean =>
|
|
119
|
+
ch === '{' || ch === '[' || ch === '(';
|
|
120
|
+
|
|
121
|
+
const appendBalancedCharacter = (
|
|
122
|
+
sourceText: string,
|
|
123
|
+
state: ScanState,
|
|
124
|
+
index: number,
|
|
125
|
+
text: string
|
|
126
|
+
): string => {
|
|
127
|
+
const ch = sourceText[index];
|
|
128
|
+
if (!ch) {
|
|
129
|
+
return text;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
scanCharacter(state, ch);
|
|
133
|
+
return `${text}${ch}`;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const captureBalanced = (
|
|
137
|
+
sourceText: string,
|
|
138
|
+
startIndex: number
|
|
139
|
+
): BalancedSegment | null => {
|
|
140
|
+
if (!isBalancedOpener(sourceText[startIndex])) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const state = createState();
|
|
145
|
+
let text = '';
|
|
146
|
+
|
|
147
|
+
for (let index = startIndex; index < sourceText.length; index += 1) {
|
|
148
|
+
text = appendBalancedCharacter(sourceText, state, index, text);
|
|
149
|
+
|
|
150
|
+
if (index > startIndex && isTopLevel(state)) {
|
|
151
|
+
return { end: index, text };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const lineNumberAt = (sourceText: string, startIndex: number): number =>
|
|
159
|
+
sourceText.slice(0, startIndex).split('\n').length;
|
|
160
|
+
|
|
161
|
+
const createSplitEntry = (
|
|
162
|
+
sourceText: string,
|
|
163
|
+
startIndex: number,
|
|
164
|
+
endIndex: number
|
|
165
|
+
): SplitEntry | null => {
|
|
166
|
+
const raw = sourceText.slice(startIndex, endIndex);
|
|
167
|
+
const firstContent = raw.search(/\S/);
|
|
168
|
+
if (firstContent === -1) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const trailingWhitespace = raw.match(/\s*$/)?.[0].length ?? 0;
|
|
173
|
+
const trimmedEnd = raw.length - trailingWhitespace;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
start: startIndex + firstContent,
|
|
177
|
+
text: raw.slice(firstContent, trimmedEnd),
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const pushSplitEntry = (
|
|
182
|
+
entries: SplitEntry[],
|
|
183
|
+
sourceText: string,
|
|
184
|
+
startIndex: number,
|
|
185
|
+
endIndex: number
|
|
186
|
+
): void => {
|
|
187
|
+
const entry = createSplitEntry(sourceText, startIndex, endIndex);
|
|
188
|
+
if (entry !== null) {
|
|
189
|
+
entries.push(entry);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const processSplitCharacter = (
|
|
194
|
+
entries: SplitEntry[],
|
|
195
|
+
sourceText: string,
|
|
196
|
+
state: ScanState,
|
|
197
|
+
entryStart: number,
|
|
198
|
+
index: number
|
|
199
|
+
): number => {
|
|
200
|
+
const ch = sourceText[index];
|
|
201
|
+
if (!ch) {
|
|
202
|
+
return entryStart;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (ch === ',' && isTopLevel(state)) {
|
|
206
|
+
pushSplitEntry(entries, sourceText, entryStart, index);
|
|
207
|
+
return index + 1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
scanCharacter(state, ch);
|
|
211
|
+
return entryStart;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const splitTopLevelEntriesWithOffsets = (
|
|
215
|
+
sourceText: string
|
|
216
|
+
): SplitEntry[] => {
|
|
217
|
+
const entries: SplitEntry[] = [];
|
|
218
|
+
const state = createState();
|
|
219
|
+
let entryStart = 0;
|
|
220
|
+
|
|
221
|
+
for (let index = 0; index < sourceText.length; index += 1) {
|
|
222
|
+
entryStart = processSplitCharacter(
|
|
223
|
+
entries,
|
|
224
|
+
sourceText,
|
|
225
|
+
state,
|
|
226
|
+
entryStart,
|
|
227
|
+
index
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
pushSplitEntry(entries, sourceText, entryStart, sourceText.length);
|
|
232
|
+
|
|
233
|
+
return entries;
|
|
234
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Severity level for warden diagnostics.
|
|
3
|
+
*/
|
|
4
|
+
export type WardenSeverity = 'error' | 'warn';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A single diagnostic reported by a warden rule.
|
|
8
|
+
*/
|
|
9
|
+
export interface WardenDiagnostic {
|
|
10
|
+
/** Rule identifier, e.g. "no-throw-in-implementation" */
|
|
11
|
+
readonly rule: string;
|
|
12
|
+
/** Severity level */
|
|
13
|
+
readonly severity: WardenSeverity;
|
|
14
|
+
/** Human-readable message describing the violation */
|
|
15
|
+
readonly message: string;
|
|
16
|
+
/** 1-based line number where the violation was detected */
|
|
17
|
+
readonly line: number;
|
|
18
|
+
/** File path that was analyzed */
|
|
19
|
+
readonly filePath: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A warden rule is a function that analyzes source code and returns diagnostics.
|
|
24
|
+
*
|
|
25
|
+
* Rules use string/regex analysis (not full AST parsing) to detect patterns
|
|
26
|
+
* that violate Trails conventions.
|
|
27
|
+
*/
|
|
28
|
+
export interface WardenRule {
|
|
29
|
+
/** Unique rule identifier */
|
|
30
|
+
readonly name: string;
|
|
31
|
+
/** Default severity */
|
|
32
|
+
readonly severity: WardenSeverity;
|
|
33
|
+
/** Human-readable description of what the rule enforces */
|
|
34
|
+
readonly description: string;
|
|
35
|
+
/** Run the rule against source code and return any diagnostics */
|
|
36
|
+
readonly check: (
|
|
37
|
+
sourceCode: string,
|
|
38
|
+
filePath: string
|
|
39
|
+
) => readonly WardenDiagnostic[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Options for cross-file rules that need knowledge of all trail IDs in a project.
|
|
44
|
+
*/
|
|
45
|
+
export interface ProjectContext {
|
|
46
|
+
/** All known trail IDs in the project */
|
|
47
|
+
readonly knownTrailIds: ReadonlySet<string>;
|
|
48
|
+
/** All trail IDs referenced as detour targets across the project */
|
|
49
|
+
readonly detourTargetTrailIds?: ReadonlySet<string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A project-aware rule that requires knowledge of all trail IDs.
|
|
54
|
+
*/
|
|
55
|
+
export interface ProjectAwareWardenRule extends WardenRule {
|
|
56
|
+
/** Run the rule with project-level context */
|
|
57
|
+
readonly checkWithContext: (
|
|
58
|
+
sourceCode: string,
|
|
59
|
+
filePath: string,
|
|
60
|
+
context: ProjectContext
|
|
61
|
+
) => readonly WardenDiagnostic[];
|
|
62
|
+
}
|