atmn 1.1.25 → 2.0.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/README.md +120 -0
- package/dist/bin.js +23102 -0
- package/dist/index.js +2906 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +27 -74
- package/src/actions/api/callApi.ts +280 -0
- package/src/actions/api/registerApiCommands.ts +121 -0
- package/src/actions/env/fetchOrgInfo.ts +32 -0
- package/src/actions/env/types/orgInfo.ts +18 -0
- package/src/actions/env.ts +94 -0
- package/src/actions/init/runInit.ts +401 -0
- package/src/actions/login/keyless.ts +135 -0
- package/src/actions/login.ts +144 -0
- package/src/actions/pull/appendPlanVersionFixture.ts +373 -0
- package/src/actions/pull/applyPreview.ts +553 -0
- package/src/actions/pull/applySettingsPreview.ts +114 -0
- package/src/actions/pull/changedFixtureKeys.ts +88 -0
- package/src/actions/pull/listSourceFiles.ts +29 -0
- package/src/actions/pull/locateFixture.ts +78 -0
- package/src/actions/pull/resolveCollectionTarget.ts +198 -0
- package/src/actions/pull/rewriteConfig.ts +57 -0
- package/src/actions/pull/scaffoldConfig.ts +118 -0
- package/src/actions/pull.ts +399 -0
- package/src/actions/push/backfillInternalIds.ts +394 -0
- package/src/actions/push/deprecatedFields.ts +55 -0
- package/src/actions/push.ts +294 -0
- package/src/actions/reset/runReset.ts +58 -0
- package/src/actions/sandbox/createSandbox.ts +83 -0
- package/src/actions/sandbox/deleteSandbox.ts +91 -0
- package/src/actions/sandbox/listSandboxes.ts +28 -0
- package/src/actions/sandbox/types/sandboxClient.ts +16 -0
- package/src/actions/sandbox/useSandbox.ts +157 -0
- package/src/actions/sandbox/withSandboxScopeHint.ts +27 -0
- package/src/actions/skills/skills.ts +246 -0
- package/src/auth/announceAuthorizationUrl.ts +26 -0
- package/src/auth/browser/openSystemBrowser.ts +7 -0
- package/src/auth/browser/tryOpenBrowser.ts +17 -0
- package/src/auth/browser/watchLauncher.ts +46 -0
- package/src/auth/buildAuthorizationUrl.ts +36 -0
- package/src/auth/callbackPages.ts +126 -0
- package/src/auth/createOrgApiKeys.ts +46 -0
- package/src/auth/keyless.ts +119 -0
- package/src/auth/oauthConfig.ts +63 -0
- package/src/auth/runOAuthFlow.ts +230 -0
- package/src/auth/types/browserOpener.ts +5 -0
- package/src/auth/types/impersonationTokens.ts +23 -0
- package/src/auth/types/oauthTokens.ts +13 -0
- package/src/auth/types/orgApiKeys.ts +6 -0
- package/src/bin.ts +9 -0
- package/src/cli.ts +648 -0
- package/src/config/configPackageName.ts +9 -0
- package/src/config/legacyConfig.ts +25 -0
- package/src/config/loadConfig.ts +231 -0
- package/src/env/assertSandboxTarget.ts +20 -0
- package/src/env/loadEnv.ts +184 -0
- package/src/env/resolveTarget.ts +134 -0
- package/src/env/sandboxKeyName.ts +18 -0
- package/src/generated/apiRoutes.ts +3787 -0
- package/src/generated/client.ts +51564 -0
- package/src/generated/emit.ts +1163 -0
- package/src/generated/emitRuntime.ts +522 -0
- package/src/generated/features.ts +146 -0
- package/src/generated/labels.ts +29 -0
- package/src/generated/licenses.ts +287 -0
- package/src/generated/lintRules.ts +2207 -0
- package/src/generated/lintRuntime.ts +865 -0
- package/src/generated/plans.ts +1628 -0
- package/src/generated/referralPrograms.ts +22 -0
- package/src/generated/rewards.ts +58 -0
- package/src/generated/settings.ts +21 -0
- package/src/generated/skills.ts +305 -0
- package/src/generated/variants.ts +934 -0
- package/src/generated/wire.ts +334 -0
- package/src/http/autumnFetch.ts +30 -0
- package/src/index.ts +20 -0
- package/src/project/chooseConfigDir.ts +41 -0
- package/src/project/resolveProject.ts +115 -0
- package/src/project/rootMarker.ts +40 -0
- package/src/prompt/prompt.ts +186 -0
- package/src/prompt/select.ts +162 -0
- package/src/render/renderEnv.ts +77 -0
- package/src/render/renderPreview.ts +945 -0
- package/src/render/renderSandboxes.ts +92 -0
- package/src/render/stripTerminalControls.ts +19 -0
- package/src/repo/findRepoRoot.ts +79 -0
- package/src/surgery/appendPropertyEdit.ts +67 -0
- package/src/surgery/appendToArray.ts +87 -0
- package/src/surgery/appendToBinding.ts +19 -0
- package/src/surgery/appendToCollection.ts +40 -0
- package/src/surgery/appendToFixtureArray.ts +71 -0
- package/src/surgery/arrayBinding.ts +30 -0
- package/src/surgery/deleteFixtureLiteral.ts +81 -0
- package/src/surgery/deleteReference.ts +48 -0
- package/src/surgery/ensureBuilderImport.ts +65 -0
- package/src/surgery/findFixture.ts +238 -0
- package/src/surgery/fixtureEdit.ts +156 -0
- package/src/surgery/fixtureLocation.ts +32 -0
- package/src/surgery/insertCollection.ts +86 -0
- package/src/surgery/insertFirstProperty.ts +73 -0
- package/src/surgery/patchFixtureProperty.ts +152 -0
- package/src/surgery/patchSingletonProperty.ts +221 -0
- package/src/surgery/replaceFixture.ts +28 -0
- package/src/surgery/setFixtureProperty.ts +55 -0
- package/src/surgery/staticFixtureRule.ts +48 -0
- package/src/version.ts +5 -0
- package/dist/cli.js +0 -146296
- package/dist/compose/index.js +0 -122
- package/dist/src/compose/builders/builderFunctions.d.ts +0 -84
- package/dist/src/compose/builders/rewardFunctions.d.ts +0 -5
- package/dist/src/compose/builders/variantFunctions.d.ts +0 -2
- package/dist/src/compose/index.d.ts +0 -19
- package/dist/src/compose/models/featureModels.d.ts +0 -262
- package/dist/src/compose/models/index.d.ts +0 -3
- package/dist/src/compose/models/planModels.d.ts +0 -562
- package/dist/src/compose/models/rewardModels.d.ts +0 -52
- package/dist/src/compose/models/variantModels.d.ts +0 -34
- package/readme.md +0 -186
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
// Copied by @autumn/atmn-generator from packages/atmn-generator/src/lint/runtime/lintDocument.ts.
|
|
2
|
+
// Do not edit — change that file and run `bun generate` instead.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Copied verbatim into the CLI by the generator, so this file imports nothing:
|
|
6
|
+
* the rule vocabulary and what each rule means live together here on purpose.
|
|
7
|
+
*
|
|
8
|
+
* Linting locally, before any network call, is what lets a config report every
|
|
9
|
+
* problem at once — a round trip per mistake is what makes one painful to write.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type LintIssue = { path: string; message: string };
|
|
13
|
+
|
|
14
|
+
export class ConfigError extends Error {
|
|
15
|
+
readonly issues: LintIssue[];
|
|
16
|
+
|
|
17
|
+
constructor(issues: LintIssue[]) {
|
|
18
|
+
super(
|
|
19
|
+
[
|
|
20
|
+
`${issues.length} problem${issues.length === 1 ? "" : "s"} in your config:`,
|
|
21
|
+
"",
|
|
22
|
+
...issues.map((issue) => ` ${issue.path}\n ${issue.message}`),
|
|
23
|
+
].join("\n"),
|
|
24
|
+
);
|
|
25
|
+
this.name = "ConfigError";
|
|
26
|
+
this.issues = issues;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every one of these is a zod call that survived into the spec. */
|
|
31
|
+
export type FieldConstraints = {
|
|
32
|
+
readonly enum?: readonly unknown[];
|
|
33
|
+
readonly minimum?: number;
|
|
34
|
+
readonly maximum?: number;
|
|
35
|
+
readonly exclusiveMinimum?: number;
|
|
36
|
+
readonly exclusiveMaximum?: number;
|
|
37
|
+
readonly minLength?: number;
|
|
38
|
+
readonly maxLength?: number;
|
|
39
|
+
readonly minItems?: number;
|
|
40
|
+
readonly maxItems?: number;
|
|
41
|
+
readonly pattern?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Rules the spec cannot express. Data, never predicates: they are serialised. */
|
|
45
|
+
export type LintRule =
|
|
46
|
+
| {
|
|
47
|
+
readonly kind: "requiredWhen";
|
|
48
|
+
readonly when: string;
|
|
49
|
+
readonly equals: string;
|
|
50
|
+
readonly require: readonly string[];
|
|
51
|
+
readonly because: string;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
readonly kind: "forbiddenWhen";
|
|
55
|
+
readonly when: string;
|
|
56
|
+
readonly equals: string;
|
|
57
|
+
readonly forbid: readonly string[];
|
|
58
|
+
readonly because: string;
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
/** At most one of `fields` may be set. */
|
|
62
|
+
readonly kind: "mutex";
|
|
63
|
+
readonly fields: readonly string[];
|
|
64
|
+
readonly because: string;
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
/** Precisely one of `fields` must be set. */
|
|
68
|
+
readonly kind: "exactlyOne";
|
|
69
|
+
readonly fields: readonly string[];
|
|
70
|
+
readonly because: string;
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
/** No two entries share `field` (or the first stated path), paired with
|
|
74
|
+
* `alongside` when given. An omitted `alongside` is not keyed as a default. */
|
|
75
|
+
readonly kind: "unique";
|
|
76
|
+
readonly field: string | readonly string[];
|
|
77
|
+
readonly alongside?: string;
|
|
78
|
+
readonly absentMeans?: string;
|
|
79
|
+
readonly because: string;
|
|
80
|
+
}
|
|
81
|
+
| {
|
|
82
|
+
/** No two entries sharing `groupBy` may link the same entry of their
|
|
83
|
+
* own `collection`. A link is identified by `identity` plus the first
|
|
84
|
+
* `pins` field it states; the entries themselves are named by
|
|
85
|
+
* `namedBy`, reading as `absentMeans` when they state none. */
|
|
86
|
+
readonly kind: "linkedOnce";
|
|
87
|
+
readonly groupBy: string;
|
|
88
|
+
readonly namedBy: string;
|
|
89
|
+
readonly absentMeans: string;
|
|
90
|
+
readonly collection: string;
|
|
91
|
+
readonly identity: string;
|
|
92
|
+
readonly pins: readonly string[];
|
|
93
|
+
readonly because: string;
|
|
94
|
+
}
|
|
95
|
+
| {
|
|
96
|
+
/** Among the entries sharing `groupBy`, exactly one has `field: true`. */
|
|
97
|
+
readonly kind: "exactlyOneActive";
|
|
98
|
+
readonly groupBy: string;
|
|
99
|
+
readonly field: string;
|
|
100
|
+
readonly label: string;
|
|
101
|
+
readonly because: string;
|
|
102
|
+
}
|
|
103
|
+
| {
|
|
104
|
+
/** `field` names an entry of top-level collection `in` by `matching`.
|
|
105
|
+
* An array-valued `field` is checked element-wise; a list of `matching`
|
|
106
|
+
* paths matches on whichever the candidate states. Skipped when that
|
|
107
|
+
* collection is absent: absent means "not mine". */
|
|
108
|
+
readonly kind: "exists";
|
|
109
|
+
readonly field: string;
|
|
110
|
+
readonly in: string;
|
|
111
|
+
readonly matching: string | readonly string[];
|
|
112
|
+
readonly because: string;
|
|
113
|
+
}
|
|
114
|
+
| {
|
|
115
|
+
readonly kind: "compare";
|
|
116
|
+
readonly field: string;
|
|
117
|
+
readonly op: "<" | "<=" | ">" | ">=";
|
|
118
|
+
readonly than: string;
|
|
119
|
+
readonly because: string;
|
|
120
|
+
}
|
|
121
|
+
| {
|
|
122
|
+
/** `field` must equal `mustBe` when `when` is `equals`. */
|
|
123
|
+
readonly kind: "valueWhen";
|
|
124
|
+
readonly when: string;
|
|
125
|
+
readonly equals: string;
|
|
126
|
+
readonly field: string;
|
|
127
|
+
readonly mustBe: string;
|
|
128
|
+
readonly because: string;
|
|
129
|
+
}
|
|
130
|
+
| {
|
|
131
|
+
/** `field` names a row of top-level collection `in` by `matching`;
|
|
132
|
+
* that row's `target` must equal `equals`. Skipped when `when` is unset. */
|
|
133
|
+
readonly kind: "targetHas";
|
|
134
|
+
readonly when: string;
|
|
135
|
+
readonly field: string;
|
|
136
|
+
readonly in: string;
|
|
137
|
+
readonly matching: string;
|
|
138
|
+
readonly target: string;
|
|
139
|
+
readonly equals: string;
|
|
140
|
+
readonly because: string;
|
|
141
|
+
}
|
|
142
|
+
| {
|
|
143
|
+
/** `field` names a row of top-level collection `in` by `matching`,
|
|
144
|
+
* shown in the message as `label`; that row's `target` must not be
|
|
145
|
+
* `true`. Skipped when the entry's own parent already has
|
|
146
|
+
* `parentGuard` true — an archived plan may reference whatever
|
|
147
|
+
* archived features it likes. `parentLabel`/`parentIdField` name
|
|
148
|
+
* that parent in the message. */
|
|
149
|
+
readonly kind: "targetLacks";
|
|
150
|
+
readonly field: string;
|
|
151
|
+
readonly in: string;
|
|
152
|
+
readonly matching: string;
|
|
153
|
+
readonly target: string;
|
|
154
|
+
readonly label: string;
|
|
155
|
+
readonly parentGuard: string;
|
|
156
|
+
readonly parentIdField: string;
|
|
157
|
+
readonly parentLabel: string;
|
|
158
|
+
readonly because: string;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** The part of a node's rules that an anyOf/oneOf branch can override. */
|
|
162
|
+
export type ShapeRules = {
|
|
163
|
+
readonly required?: readonly string[];
|
|
164
|
+
readonly fields?: Readonly<Record<string, FieldConstraints>>;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export type NodeRules = ShapeRules & {
|
|
168
|
+
/** How an entry is named in a breadcrumb; the key name when absent. */
|
|
169
|
+
readonly label?: string;
|
|
170
|
+
/** Field whose value names one entry, e.g. `featureId`. */
|
|
171
|
+
readonly idField?: string;
|
|
172
|
+
/** Record keys are user data; the spec constrains them via `propertyNames`. */
|
|
173
|
+
readonly keys?: FieldConstraints;
|
|
174
|
+
readonly rules?: readonly LintRule[];
|
|
175
|
+
/** anyOf/oneOf alternatives, chosen by the value of `on`. */
|
|
176
|
+
readonly variants?: {
|
|
177
|
+
readonly on: string;
|
|
178
|
+
readonly byValue: Readonly<Record<string, ShapeRules>>;
|
|
179
|
+
/** The branch(es) that do not name `on` at all. */
|
|
180
|
+
readonly fallback?: ShapeRules;
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Keyed by fixture path with array indices elided — `features.creditSchema`. */
|
|
185
|
+
export type LintRules = Readonly<Record<string, NodeRules>>;
|
|
186
|
+
|
|
187
|
+
export type LintHints = {
|
|
188
|
+
readonly recordPaths: ReadonlySet<string>;
|
|
189
|
+
readonly frozenPaths: ReadonlySet<string>;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
type Entry = Record<string, unknown>;
|
|
193
|
+
|
|
194
|
+
type Walk = {
|
|
195
|
+
readonly document: Entry;
|
|
196
|
+
readonly rules: LintRules;
|
|
197
|
+
readonly hints: LintHints;
|
|
198
|
+
readonly issues: LintIssue[];
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const isEntry = (value: unknown): value is Entry =>
|
|
202
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
203
|
+
|
|
204
|
+
/** Dotted lookup, so a rule can name a field inside a union branch. */
|
|
205
|
+
const valueAtPath = ({
|
|
206
|
+
entry,
|
|
207
|
+
path,
|
|
208
|
+
}: {
|
|
209
|
+
entry: Entry;
|
|
210
|
+
path: string;
|
|
211
|
+
}): unknown => {
|
|
212
|
+
let current: unknown = entry;
|
|
213
|
+
for (const segment of path.split(".")) {
|
|
214
|
+
if (!isEntry(current)) return undefined;
|
|
215
|
+
current = current[segment];
|
|
216
|
+
}
|
|
217
|
+
return current;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** The first of `paths` the entry states — a union item is named by its branch. */
|
|
221
|
+
const firstValueAtPaths = ({
|
|
222
|
+
entry,
|
|
223
|
+
paths,
|
|
224
|
+
}: {
|
|
225
|
+
entry: Entry;
|
|
226
|
+
paths: string | readonly string[];
|
|
227
|
+
}): unknown => {
|
|
228
|
+
for (const path of typeof paths === "string" ? [paths] : paths) {
|
|
229
|
+
const value = valueAtPath({ entry, path });
|
|
230
|
+
if (value !== undefined) return value;
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const show = (value: unknown): string => JSON.stringify(value);
|
|
236
|
+
|
|
237
|
+
const list = (values: readonly unknown[]): string =>
|
|
238
|
+
values.map(show).join(", ");
|
|
239
|
+
|
|
240
|
+
const regexCache = new Map<string, RegExp>();
|
|
241
|
+
const regexFor = (pattern: string): RegExp => {
|
|
242
|
+
const cached = regexCache.get(pattern);
|
|
243
|
+
if (cached) return cached;
|
|
244
|
+
const compiled = new RegExp(pattern);
|
|
245
|
+
regexCache.set(pattern, compiled);
|
|
246
|
+
return compiled;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const constraintFailures = ({
|
|
250
|
+
name,
|
|
251
|
+
value,
|
|
252
|
+
constraints,
|
|
253
|
+
}: {
|
|
254
|
+
name: string;
|
|
255
|
+
value: unknown;
|
|
256
|
+
constraints: FieldConstraints;
|
|
257
|
+
}): string[] => {
|
|
258
|
+
const failures: string[] = [];
|
|
259
|
+
const c = constraints;
|
|
260
|
+
|
|
261
|
+
if (c.enum && !c.enum.includes(value)) {
|
|
262
|
+
failures.push(
|
|
263
|
+
`${name} must be one of ${list(c.enum)} — got ${show(value)}.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (typeof value === "number") {
|
|
268
|
+
if (c.minimum !== undefined && value < c.minimum)
|
|
269
|
+
failures.push(`${name} must be at least ${c.minimum} — got ${value}.`);
|
|
270
|
+
if (c.maximum !== undefined && value > c.maximum)
|
|
271
|
+
failures.push(`${name} must be at most ${c.maximum} — got ${value}.`);
|
|
272
|
+
if (c.exclusiveMinimum !== undefined && value <= c.exclusiveMinimum)
|
|
273
|
+
failures.push(
|
|
274
|
+
`${name} must be greater than ${c.exclusiveMinimum} — got ${value}.`,
|
|
275
|
+
);
|
|
276
|
+
if (c.exclusiveMaximum !== undefined && value >= c.exclusiveMaximum)
|
|
277
|
+
failures.push(
|
|
278
|
+
`${name} must be less than ${c.exclusiveMaximum} — got ${value}.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (typeof value === "string") {
|
|
283
|
+
if (c.minLength !== undefined && value.length < c.minLength) {
|
|
284
|
+
failures.push(
|
|
285
|
+
c.minLength === 1
|
|
286
|
+
? `${name} must not be empty.`
|
|
287
|
+
: `${name} must be at least ${c.minLength} characters — got ${value.length}.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (c.maxLength !== undefined && value.length > c.maxLength)
|
|
291
|
+
failures.push(
|
|
292
|
+
`${name} must be at most ${c.maxLength} characters — got ${value.length}.`,
|
|
293
|
+
);
|
|
294
|
+
if (c.pattern !== undefined && !regexFor(c.pattern).test(value))
|
|
295
|
+
failures.push(`${name} must match ${c.pattern} — got ${show(value)}.`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (Array.isArray(value)) {
|
|
299
|
+
if (c.minItems !== undefined && value.length < c.minItems)
|
|
300
|
+
failures.push(
|
|
301
|
+
`${name} must have at least ${c.minItems} ${c.minItems === 1 ? "entry" : "entries"}.`,
|
|
302
|
+
);
|
|
303
|
+
if (c.maxItems !== undefined && value.length > c.maxItems)
|
|
304
|
+
failures.push(`${name} must have at most ${c.maxItems} entries.`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return failures;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const checkShape = ({
|
|
311
|
+
entry,
|
|
312
|
+
shape,
|
|
313
|
+
at,
|
|
314
|
+
issues,
|
|
315
|
+
}: {
|
|
316
|
+
entry: Entry;
|
|
317
|
+
shape: ShapeRules;
|
|
318
|
+
at: string;
|
|
319
|
+
issues: LintIssue[];
|
|
320
|
+
}): void => {
|
|
321
|
+
for (const field of shape.required ?? []) {
|
|
322
|
+
if (entry[field] === undefined)
|
|
323
|
+
issues.push({ path: at, message: `${field} is required.` });
|
|
324
|
+
}
|
|
325
|
+
for (const [field, constraints] of Object.entries(shape.fields ?? {})) {
|
|
326
|
+
if (entry[field] === undefined) continue;
|
|
327
|
+
for (const message of constraintFailures({
|
|
328
|
+
name: field,
|
|
329
|
+
value: entry[field],
|
|
330
|
+
constraints,
|
|
331
|
+
})) {
|
|
332
|
+
issues.push({ path: at, message });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const checkVariants = ({
|
|
338
|
+
entry,
|
|
339
|
+
variants,
|
|
340
|
+
at,
|
|
341
|
+
issues,
|
|
342
|
+
}: {
|
|
343
|
+
entry: Entry;
|
|
344
|
+
variants: NonNullable<NodeRules["variants"]>;
|
|
345
|
+
at: string;
|
|
346
|
+
issues: LintIssue[];
|
|
347
|
+
}): void => {
|
|
348
|
+
const selector = entry[variants.on];
|
|
349
|
+
if (selector === undefined) {
|
|
350
|
+
if (variants.fallback) {
|
|
351
|
+
checkShape({ entry, shape: variants.fallback, at, issues });
|
|
352
|
+
} else {
|
|
353
|
+
issues.push({ path: at, message: `${variants.on} is required.` });
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const chosen =
|
|
358
|
+
typeof selector === "string" ? variants.byValue[selector] : undefined;
|
|
359
|
+
if (!chosen) {
|
|
360
|
+
issues.push({
|
|
361
|
+
path: at,
|
|
362
|
+
message: `${variants.on} must be one of ${list(Object.keys(variants.byValue))} — got ${show(selector)}.`,
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
checkShape({ entry, shape: chosen, at, issues });
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const crumbFor = ({
|
|
370
|
+
node,
|
|
371
|
+
key,
|
|
372
|
+
entry,
|
|
373
|
+
index,
|
|
374
|
+
}: {
|
|
375
|
+
node: NodeRules | undefined;
|
|
376
|
+
key: string;
|
|
377
|
+
entry: Entry;
|
|
378
|
+
index: number;
|
|
379
|
+
}): string => {
|
|
380
|
+
const label = node?.label ?? key;
|
|
381
|
+
const id = node?.idField ? entry[node.idField] : undefined;
|
|
382
|
+
return typeof id === "string" && id
|
|
383
|
+
? `${label} ${show(id)}`
|
|
384
|
+
: `${label}[${index}]`;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const render = (trail: readonly string[]): string =>
|
|
388
|
+
trail.length === 0 ? "config" : trail.join(" › ");
|
|
389
|
+
|
|
390
|
+
const setFields = ({
|
|
391
|
+
entry,
|
|
392
|
+
fields,
|
|
393
|
+
}: {
|
|
394
|
+
entry: Entry;
|
|
395
|
+
fields: readonly string[];
|
|
396
|
+
}): string[] => fields.filter((field) => entry[field] !== undefined);
|
|
397
|
+
|
|
398
|
+
const joinNames = (names: readonly string[]): string =>
|
|
399
|
+
names.length <= 1
|
|
400
|
+
? names.join("")
|
|
401
|
+
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
402
|
+
|
|
403
|
+
const COMPARE = {
|
|
404
|
+
"<": { holds: (a: number, b: number) => a < b, words: "less than" },
|
|
405
|
+
"<=": { holds: (a: number, b: number) => a <= b, words: "at most" },
|
|
406
|
+
">": { holds: (a: number, b: number) => a > b, words: "greater than" },
|
|
407
|
+
">=": { holds: (a: number, b: number) => a >= b, words: "at least" },
|
|
408
|
+
} as const;
|
|
409
|
+
|
|
410
|
+
const entryRuleFailures = ({
|
|
411
|
+
entry,
|
|
412
|
+
rule,
|
|
413
|
+
document,
|
|
414
|
+
parent,
|
|
415
|
+
}: {
|
|
416
|
+
entry: Entry;
|
|
417
|
+
rule: LintRule;
|
|
418
|
+
document: Entry;
|
|
419
|
+
parent?: Entry;
|
|
420
|
+
}): string[] => {
|
|
421
|
+
switch (rule.kind) {
|
|
422
|
+
case "requiredWhen": {
|
|
423
|
+
if (entry[rule.when] !== rule.equals) return [];
|
|
424
|
+
return rule.require
|
|
425
|
+
.filter((field) => entry[field] === undefined)
|
|
426
|
+
.map(
|
|
427
|
+
(field) =>
|
|
428
|
+
`${field} is required when ${rule.when} is ${show(rule.equals)}. ${rule.because}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
case "forbiddenWhen": {
|
|
432
|
+
if (entry[rule.when] !== rule.equals) return [];
|
|
433
|
+
return setFields({ entry, fields: rule.forbid }).map(
|
|
434
|
+
(field) =>
|
|
435
|
+
`${field} cannot be set when ${rule.when} is ${show(rule.equals)}. ${rule.because}`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
case "mutex": {
|
|
439
|
+
const set = setFields({ entry, fields: rule.fields });
|
|
440
|
+
if (set.length <= 1) return [];
|
|
441
|
+
return [`${joinNames(set)} cannot be set together. ${rule.because}`];
|
|
442
|
+
}
|
|
443
|
+
case "exactlyOne": {
|
|
444
|
+
const set = setFields({ entry, fields: rule.fields });
|
|
445
|
+
if (set.length === 1) return [];
|
|
446
|
+
if (set.length === 0)
|
|
447
|
+
return [
|
|
448
|
+
`One of ${joinNames(rule.fields)} is required. ${rule.because}`,
|
|
449
|
+
];
|
|
450
|
+
return [`${joinNames(set)} cannot be set together. ${rule.because}`];
|
|
451
|
+
}
|
|
452
|
+
case "exists": {
|
|
453
|
+
const value = valueAtPath({ entry, path: rule.field });
|
|
454
|
+
const target = document[rule.in];
|
|
455
|
+
if (value === undefined || value === null || !Array.isArray(target))
|
|
456
|
+
return [];
|
|
457
|
+
const names = (wanted: unknown): boolean =>
|
|
458
|
+
target.some(
|
|
459
|
+
(candidate) =>
|
|
460
|
+
isEntry(candidate) &&
|
|
461
|
+
firstValueAtPaths({ entry: candidate, paths: rule.matching }) ===
|
|
462
|
+
wanted,
|
|
463
|
+
);
|
|
464
|
+
const missing = (Array.isArray(value) ? value : [value]).filter(
|
|
465
|
+
(wanted) => !names(wanted),
|
|
466
|
+
);
|
|
467
|
+
return missing.map(
|
|
468
|
+
(wanted) =>
|
|
469
|
+
`${rule.field} ${show(wanted)} is not in ${rule.in}. ${rule.because}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
case "compare": {
|
|
473
|
+
const a = entry[rule.field];
|
|
474
|
+
const b = entry[rule.than];
|
|
475
|
+
if (typeof a !== "number" || typeof b !== "number") return [];
|
|
476
|
+
const { holds, words } = COMPARE[rule.op];
|
|
477
|
+
return holds(a, b)
|
|
478
|
+
? []
|
|
479
|
+
: [
|
|
480
|
+
`${rule.field} must be ${words} ${rule.than} — got ${a} and ${b}. ${rule.because}`,
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
case "valueWhen": {
|
|
484
|
+
if (entry[rule.when] !== rule.equals) return [];
|
|
485
|
+
const value = entry[rule.field];
|
|
486
|
+
if (value === undefined || value === rule.mustBe) return [];
|
|
487
|
+
return [
|
|
488
|
+
`${rule.field} must be ${show(rule.mustBe)} when ${rule.when} is ${show(rule.equals)}. ${rule.because}`,
|
|
489
|
+
];
|
|
490
|
+
}
|
|
491
|
+
case "targetHas": {
|
|
492
|
+
if (entry[rule.when] === undefined) return [];
|
|
493
|
+
const target = document[rule.in];
|
|
494
|
+
if (!Array.isArray(target)) return [];
|
|
495
|
+
const row = target.find(
|
|
496
|
+
(candidate) =>
|
|
497
|
+
isEntry(candidate) && candidate[rule.matching] === entry[rule.field],
|
|
498
|
+
);
|
|
499
|
+
if (!row || row[rule.target] === rule.equals) return [];
|
|
500
|
+
return [
|
|
501
|
+
`${rule.when} needs ${rule.in} ${show(entry[rule.field])} to have ${rule.target} ${show(rule.equals)} — got ${show(row[rule.target])}. ${rule.because}`,
|
|
502
|
+
];
|
|
503
|
+
}
|
|
504
|
+
case "targetLacks": {
|
|
505
|
+
const target = document[rule.in];
|
|
506
|
+
if (!Array.isArray(target)) return [];
|
|
507
|
+
const row = target.find(
|
|
508
|
+
(candidate) =>
|
|
509
|
+
isEntry(candidate) && candidate[rule.matching] === entry[rule.field],
|
|
510
|
+
);
|
|
511
|
+
if (!row || row[rule.target] !== true) return [];
|
|
512
|
+
if (parent?.[rule.parentGuard] === true) return [];
|
|
513
|
+
return [
|
|
514
|
+
`${rule.label} ${show(entry[rule.field])} is ${rule.target}. Unarchive it, or archive ${rule.parentLabel} ${show(parent?.[rule.parentIdField])}. ${rule.because}`,
|
|
515
|
+
];
|
|
516
|
+
}
|
|
517
|
+
case "unique":
|
|
518
|
+
case "linkedOnce":
|
|
519
|
+
case "exactlyOneActive":
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
type CollectionCheck<K extends LintRule["kind"]> = {
|
|
525
|
+
entries: unknown[];
|
|
526
|
+
rule: Extract<LintRule, { kind: K }>;
|
|
527
|
+
node: NodeRules;
|
|
528
|
+
key: string;
|
|
529
|
+
trail: readonly string[];
|
|
530
|
+
issues: LintIssue[];
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const checkUnique = ({
|
|
534
|
+
entries,
|
|
535
|
+
rule,
|
|
536
|
+
node,
|
|
537
|
+
key,
|
|
538
|
+
trail,
|
|
539
|
+
issues,
|
|
540
|
+
}: CollectionCheck<"unique">): void => {
|
|
541
|
+
const seen = new Set<string>();
|
|
542
|
+
for (const [index, entry] of entries.entries()) {
|
|
543
|
+
if (!isEntry(entry)) continue;
|
|
544
|
+
const value = firstValueAtPaths({ entry, paths: rule.field });
|
|
545
|
+
if (typeof value !== "string") continue;
|
|
546
|
+
const fieldName =
|
|
547
|
+
typeof rule.field === "string" ? rule.field : rule.field.join(" / ");
|
|
548
|
+
const pair =
|
|
549
|
+
rule.alongside === undefined ? undefined : entry[rule.alongside];
|
|
550
|
+
if (rule.alongside !== undefined && typeof pair !== "string") continue;
|
|
551
|
+
const composite = rule.alongside === undefined ? value : `${value}@${pair}`;
|
|
552
|
+
if (seen.has(composite)) {
|
|
553
|
+
const label =
|
|
554
|
+
rule.alongside === undefined
|
|
555
|
+
? `${fieldName} ${show(value)}`
|
|
556
|
+
: `${fieldName} ${show(value)} with ${rule.alongside} ${show(pair)}`;
|
|
557
|
+
issues.push({
|
|
558
|
+
path: render([...trail, crumbFor({ node, key, entry, index })]),
|
|
559
|
+
message: `${label} is used more than once. ${rule.because}`,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
seen.add(composite);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
/** A pin's identity and how it reads: `version: 1` and `versionSlug: "v1"`
|
|
567
|
+
* both display as `v1` but can name different rows, so the field is part of
|
|
568
|
+
* what the link is keyed by. */
|
|
569
|
+
type Pin = { key: string; display: string };
|
|
570
|
+
|
|
571
|
+
/** The first pin an entry states — what names the row it addresses. */
|
|
572
|
+
const pinOf = ({
|
|
573
|
+
entry,
|
|
574
|
+
pins,
|
|
575
|
+
}: {
|
|
576
|
+
entry: Entry;
|
|
577
|
+
pins: readonly string[];
|
|
578
|
+
}): Pin | undefined => {
|
|
579
|
+
for (const pin of pins) {
|
|
580
|
+
const value = entry[pin];
|
|
581
|
+
if (typeof value === "string")
|
|
582
|
+
return { key: `${pin}:${value}`, display: value };
|
|
583
|
+
if (typeof value === "number")
|
|
584
|
+
return { key: `${pin}:${value}`, display: `v${value}` };
|
|
585
|
+
}
|
|
586
|
+
return undefined;
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const checkLinkedOnce = ({
|
|
590
|
+
entries,
|
|
591
|
+
rule,
|
|
592
|
+
node,
|
|
593
|
+
key,
|
|
594
|
+
trail,
|
|
595
|
+
issues,
|
|
596
|
+
}: CollectionCheck<"linkedOnce">): void => {
|
|
597
|
+
const ownerByLink = new Map<string, { owner: string; index: number }>();
|
|
598
|
+
for (const [index, entry] of entries.entries()) {
|
|
599
|
+
if (!isEntry(entry)) continue;
|
|
600
|
+
const group = entry[rule.groupBy];
|
|
601
|
+
const links = entry[rule.collection];
|
|
602
|
+
if (typeof group !== "string" || !Array.isArray(links)) continue;
|
|
603
|
+
const owner =
|
|
604
|
+
pinOf({ entry, pins: [rule.namedBy] })?.display ?? rule.absentMeans;
|
|
605
|
+
|
|
606
|
+
for (const link of links) {
|
|
607
|
+
if (!isEntry(link)) continue;
|
|
608
|
+
const id = link[rule.identity];
|
|
609
|
+
if (typeof id !== "string") continue;
|
|
610
|
+
const pin = pinOf({ entry: link, pins: rule.pins });
|
|
611
|
+
const label = pin === undefined ? id : `${id} ${pin.display}`;
|
|
612
|
+
const composite = `${group} ${id} ${pin?.key ?? "latest"}`;
|
|
613
|
+
const previous = ownerByLink.get(composite);
|
|
614
|
+
if (previous === undefined) {
|
|
615
|
+
ownerByLink.set(composite, { owner, index });
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const at = render([...trail, crumbFor({ node, key, entry, index })]);
|
|
619
|
+
// Nested collections have no unique rule of their own, so one entry
|
|
620
|
+
// naming a row twice is this rule's to report.
|
|
621
|
+
if (previous.index === index) {
|
|
622
|
+
issues.push({
|
|
623
|
+
path: at,
|
|
624
|
+
message: `${label} is linked twice from ${group} ${owner}. ${rule.because}`,
|
|
625
|
+
});
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
// Two entries naming one version is the unique rule's error, not this one.
|
|
629
|
+
if (previous.owner === owner) continue;
|
|
630
|
+
issues.push({
|
|
631
|
+
path: at,
|
|
632
|
+
message: `${label} is linked from ${group} ${previous.owner} and ${group} ${owner}. ${rule.because}`,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
/** Among the rows sharing a group, exactly one carries `field: true`. */
|
|
639
|
+
const checkExactlyOneActive = ({
|
|
640
|
+
entries,
|
|
641
|
+
rule,
|
|
642
|
+
node,
|
|
643
|
+
key,
|
|
644
|
+
trail,
|
|
645
|
+
issues,
|
|
646
|
+
}: CollectionCheck<"exactlyOneActive">): void => {
|
|
647
|
+
type Member = { entry: Entry; index: number; active: boolean };
|
|
648
|
+
const rowsByGroup = new Map<string, Member[]>();
|
|
649
|
+
for (const [index, entry] of entries.entries()) {
|
|
650
|
+
if (!isEntry(entry)) continue;
|
|
651
|
+
const group = entry[rule.groupBy];
|
|
652
|
+
if (typeof group !== "string") continue;
|
|
653
|
+
rowsByGroup.set(group, [
|
|
654
|
+
...(rowsByGroup.get(group) ?? []),
|
|
655
|
+
{ entry, index, active: entry[rule.field] === true },
|
|
656
|
+
]);
|
|
657
|
+
}
|
|
658
|
+
for (const [group, members] of rowsByGroup) {
|
|
659
|
+
const active = members.filter((member) => member.active);
|
|
660
|
+
if (active.length === 1) continue;
|
|
661
|
+
// Report where the reader can act: the first row when none is active,
|
|
662
|
+
// the second active row when too many are.
|
|
663
|
+
const at = active.length === 0 ? members[0] : active[1];
|
|
664
|
+
if (at === undefined) continue;
|
|
665
|
+
const versions = `${members.length} version${members.length === 1 ? "" : "s"}`;
|
|
666
|
+
const count =
|
|
667
|
+
active.length === 0 ? "none is active" : `${active.length} are active`;
|
|
668
|
+
issues.push({
|
|
669
|
+
path: render([
|
|
670
|
+
...trail,
|
|
671
|
+
crumbFor({ node, key, entry: at.entry, index: at.index }),
|
|
672
|
+
]),
|
|
673
|
+
message: `${rule.label} ${show(group)} has ${versions} and ${count}. ${rule.because}${renameHint({ active, members })}`,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* A group with no active row whose every row already has a stable id is what
|
|
680
|
+
* a half-done rename looks like: the versions moved to the new id all came
|
|
681
|
+
* from the server, and the live one stayed behind under the old id.
|
|
682
|
+
*/
|
|
683
|
+
const renameHint = <T extends { active: boolean; entry: Entry }>({
|
|
684
|
+
active,
|
|
685
|
+
members,
|
|
686
|
+
}: {
|
|
687
|
+
active: T[];
|
|
688
|
+
members: T[];
|
|
689
|
+
}): string => {
|
|
690
|
+
if (active.length !== 0) return "";
|
|
691
|
+
const everyRowIsKnown = members.every(
|
|
692
|
+
(member) => typeof member.entry.internalId === "string",
|
|
693
|
+
);
|
|
694
|
+
if (!everyRowIsKnown) return "";
|
|
695
|
+
return " If you are renaming a plan, change the planId on every one of its versions, not just some.";
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
/** Rules about the collection as a whole, reported on the offending entry. */
|
|
699
|
+
const checkCollection = ({
|
|
700
|
+
entries,
|
|
701
|
+
node,
|
|
702
|
+
key,
|
|
703
|
+
trail,
|
|
704
|
+
issues,
|
|
705
|
+
}: {
|
|
706
|
+
entries: unknown[];
|
|
707
|
+
node: NodeRules;
|
|
708
|
+
key: string;
|
|
709
|
+
trail: readonly string[];
|
|
710
|
+
issues: LintIssue[];
|
|
711
|
+
}): void => {
|
|
712
|
+
for (const rule of node.rules ?? []) {
|
|
713
|
+
if (rule.kind === "unique")
|
|
714
|
+
checkUnique({ entries, rule, node, key, trail, issues });
|
|
715
|
+
if (rule.kind === "linkedOnce")
|
|
716
|
+
checkLinkedOnce({ entries, rule, node, key, trail, issues });
|
|
717
|
+
if (rule.kind === "exactlyOneActive")
|
|
718
|
+
checkExactlyOneActive({ entries, rule, node, key, trail, issues });
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const checkEntry = ({
|
|
723
|
+
entry,
|
|
724
|
+
node,
|
|
725
|
+
at,
|
|
726
|
+
walk,
|
|
727
|
+
parent,
|
|
728
|
+
}: {
|
|
729
|
+
entry: Entry;
|
|
730
|
+
node: NodeRules;
|
|
731
|
+
at: string;
|
|
732
|
+
walk: Walk;
|
|
733
|
+
parent?: Entry;
|
|
734
|
+
}): void => {
|
|
735
|
+
checkShape({ entry, shape: node, at, issues: walk.issues });
|
|
736
|
+
if (node.variants) {
|
|
737
|
+
checkVariants({ entry, variants: node.variants, at, issues: walk.issues });
|
|
738
|
+
}
|
|
739
|
+
for (const rule of node.rules ?? []) {
|
|
740
|
+
for (const message of entryRuleFailures({
|
|
741
|
+
entry,
|
|
742
|
+
rule,
|
|
743
|
+
document: walk.document,
|
|
744
|
+
parent,
|
|
745
|
+
})) {
|
|
746
|
+
walk.issues.push({ path: at, message });
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const walkEntry = ({
|
|
752
|
+
entry,
|
|
753
|
+
path,
|
|
754
|
+
trail,
|
|
755
|
+
walk,
|
|
756
|
+
parent,
|
|
757
|
+
}: {
|
|
758
|
+
entry: Entry;
|
|
759
|
+
path: string;
|
|
760
|
+
trail: readonly string[];
|
|
761
|
+
walk: Walk;
|
|
762
|
+
parent?: Entry;
|
|
763
|
+
}): void => {
|
|
764
|
+
const node = walk.rules[path];
|
|
765
|
+
if (node) checkEntry({ entry, node, at: render(trail), walk, parent });
|
|
766
|
+
|
|
767
|
+
for (const [key, child] of Object.entries(entry)) {
|
|
768
|
+
walkValue({
|
|
769
|
+
value: child,
|
|
770
|
+
path: path ? `${path}.${key}` : key,
|
|
771
|
+
key,
|
|
772
|
+
trail,
|
|
773
|
+
walk,
|
|
774
|
+
parent: entry,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
/** Array indices are elided from the path, so one rule covers every element. */
|
|
780
|
+
const walkValue = ({
|
|
781
|
+
value,
|
|
782
|
+
path,
|
|
783
|
+
key,
|
|
784
|
+
trail,
|
|
785
|
+
walk,
|
|
786
|
+
parent,
|
|
787
|
+
}: {
|
|
788
|
+
value: unknown;
|
|
789
|
+
path: string;
|
|
790
|
+
key: string;
|
|
791
|
+
trail: readonly string[];
|
|
792
|
+
walk: Walk;
|
|
793
|
+
parent?: Entry;
|
|
794
|
+
}): void => {
|
|
795
|
+
if (walk.hints.frozenPaths.has(path)) return;
|
|
796
|
+
|
|
797
|
+
if (Array.isArray(value)) {
|
|
798
|
+
const node = walk.rules[path];
|
|
799
|
+
if (node) {
|
|
800
|
+
checkCollection({
|
|
801
|
+
entries: value,
|
|
802
|
+
node,
|
|
803
|
+
key,
|
|
804
|
+
trail,
|
|
805
|
+
issues: walk.issues,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
for (const [index, entry] of value.entries()) {
|
|
809
|
+
if (!isEntry(entry)) continue;
|
|
810
|
+
walkEntry({
|
|
811
|
+
entry,
|
|
812
|
+
path,
|
|
813
|
+
trail: [...trail, crumbFor({ node, key, entry, index })],
|
|
814
|
+
walk,
|
|
815
|
+
parent,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (!isEntry(value)) return;
|
|
822
|
+
|
|
823
|
+
// A record's keys are the user's, checked against `propertyNames`; its
|
|
824
|
+
// values are still ours, one level down at `path.*`.
|
|
825
|
+
if (walk.hints.recordPaths.has(path)) {
|
|
826
|
+
const keys = walk.rules[path]?.keys;
|
|
827
|
+
for (const [recordKey, child] of Object.entries(value)) {
|
|
828
|
+
if (keys) {
|
|
829
|
+
for (const message of constraintFailures({
|
|
830
|
+
name: `${key} key ${show(recordKey)}`,
|
|
831
|
+
value: recordKey,
|
|
832
|
+
constraints: keys,
|
|
833
|
+
})) {
|
|
834
|
+
walk.issues.push({ path: render(trail), message });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
walkValue({
|
|
838
|
+
value: child,
|
|
839
|
+
path: `${path}.*`,
|
|
840
|
+
key: `${key}[${show(recordKey)}]`,
|
|
841
|
+
trail,
|
|
842
|
+
walk,
|
|
843
|
+
parent,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
walkEntry({ entry: value, path, trail: [...trail, key], walk, parent });
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
/** Every issue in the document, in document order. */
|
|
853
|
+
export const lintDocument = ({
|
|
854
|
+
document,
|
|
855
|
+
rules,
|
|
856
|
+
hints,
|
|
857
|
+
}: {
|
|
858
|
+
document: Record<string, unknown>;
|
|
859
|
+
rules: LintRules;
|
|
860
|
+
hints: LintHints;
|
|
861
|
+
}): LintIssue[] => {
|
|
862
|
+
const walk: Walk = { document, rules, hints, issues: [] };
|
|
863
|
+
walkEntry({ entry: document, path: "", trail: [], walk });
|
|
864
|
+
return walk.issues;
|
|
865
|
+
};
|