@funnelsgrove/runtime 0.1.56 → 0.1.60
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/dist/components/FunnelContext.d.ts +5 -1
- package/dist/components/FunnelContext.js +2 -1
- package/dist/components/ManageSubscriptionScreen.js +4 -1
- package/dist/components/SubscriptionHandoffScreen.d.ts +2 -1
- package/dist/components/SubscriptionHandoffScreen.js +11 -6
- package/dist/config/funnel.manifest.types.d.ts +4 -2
- package/dist/generated/step-contract-hash.d.ts +1 -0
- package/dist/generated/step-contract-hash.js +2 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/migrations/step-contract-v2.d.ts +50 -0
- package/dist/migrations/step-contract-v2.js +310 -0
- package/dist/migrations/step-contract-v3.d.ts +41 -0
- package/dist/migrations/step-contract-v3.js +26 -0
- package/dist/runtime/funnel-manifest.validation.d.ts +7 -2
- package/dist/runtime/funnel-manifest.validation.js +24 -68
- package/dist/runtime/funnel-step-lifecycle.d.ts +35 -0
- package/dist/runtime/funnel-step-lifecycle.js +49 -0
- package/dist/runtime/funnel-step-metadata.validation.d.ts +7 -0
- package/dist/runtime/funnel-step-metadata.validation.js +88 -0
- package/dist/runtime/offer-set-runtime.d.ts +17 -0
- package/dist/runtime/offer-set-runtime.js +18 -6
- package/dist/runtime/submit-email-capture.d.ts +23 -0
- package/dist/runtime/submit-email-capture.js +54 -0
- package/dist/runtime/use-funnel-flow-controller.d.ts +58 -5
- package/dist/runtime/use-funnel-flow-controller.js +804 -122
- package/dist/runtime/use-step-choices.d.ts +58 -0
- package/dist/runtime/use-step-choices.js +187 -0
- package/dist/sdk/userAnswers.d.ts +1 -0
- package/dist/services/api.service.d.ts +10 -0
- package/dist/services/api.service.js +32 -0
- package/dist/services/funnel-sdk.service.d.ts +27 -0
- package/dist/services/funnel-sdk.service.js +18 -0
- package/dist/steps/choice-contract.d.ts +33 -0
- package/dist/steps/choice-contract.js +302 -0
- package/dist/steps/step-contract.d.ts +498 -0
- package/dist/steps/step-contract.js +506 -0
- package/dist/steps/types.d.ts +7 -5
- package/dist/steps/types.js +1 -18
- package/dist/testing/funnel-contract-journey-driver.d.ts +35 -0
- package/dist/testing/funnel-contract-journey-driver.js +266 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/validation/fixtures/invalid-reserved-identities.d.ts +249 -0
- package/dist/validation/fixtures/invalid-reserved-identities.js +20 -0
- package/dist/validation/fixtures/valid-v2-project.d.ts +419 -0
- package/dist/validation/fixtures/valid-v2-project.js +228 -0
- package/dist/validation/fixtures/valid-v3-project.d.ts +419 -0
- package/dist/validation/fixtures/valid-v3-project.js +30 -0
- package/dist/validation/funnel-contract-diagnostics.d.ts +49 -0
- package/dist/validation/funnel-contract-diagnostics.js +67 -0
- package/dist/validation/funnel-contract-validator.d.ts +30 -0
- package/dist/validation/funnel-contract-validator.js +1458 -0
- package/dist/validation/funnel-manifest-input.normalization.d.ts +67 -0
- package/dist/validation/funnel-manifest-input.normalization.js +386 -0
- package/package.json +9 -1
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
import { CURRENT_STEP_CONTRACT_HASH } from '../generated/step-contract-hash.js';
|
|
2
|
+
import { validateFunnelStepMetadataParity } from '../runtime/funnel-step-metadata.validation.js';
|
|
3
|
+
import { parseFunnelChoiceDescriptor } from '../steps/choice-contract.js';
|
|
4
|
+
import { CURRENT_STEP_CONTRACT_VERSION, FUNNEL_RESERVED_STEP_CONTRACTS, FUNNEL_STEP_CONTRACTS, FUNNEL_STEP_TYPES, LEGACY_UNVERSIONED_STEP_CONTRACT_VERSION, SUPPORTED_STEP_CONTRACT_VERSIONS, resolveReservedStepContract, resolveStepTypeContract, } from '../steps/step-contract.js';
|
|
5
|
+
import { createFunnelContractDiagnostic, } from './funnel-contract-diagnostics.js';
|
|
6
|
+
import { MAX_FUNNEL_MANIFEST_COLLECTION_ITEMS, isPlainDataRecord, normalizeFunnelManifestInput, } from './funnel-manifest-input.normalization.js';
|
|
7
|
+
const MANIFEST_FILE = 'src/config/funnel.manifest.ts';
|
|
8
|
+
const AUTHORING_GUIDE = 'docs/product-specs/funnel-template-step-authoring.md';
|
|
9
|
+
const FLOW_GUIDE = 'docs/product-specs/funnel-flow-routing.md';
|
|
10
|
+
const KEBAB_CASE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
11
|
+
const FUNNEL_STEP_KINDS = Object.freeze(Array.from(new Set(Object.values(FUNNEL_STEP_CONTRACTS).flatMap((contract) => contract.allowedKinds))));
|
|
12
|
+
const FUNNEL_STEP_KIND_SET = new Set(FUNNEL_STEP_KINDS);
|
|
13
|
+
const SUPPORTED_STEP_CONTRACT_VERSION_SET = new Set(SUPPORTED_STEP_CONTRACT_VERSIONS);
|
|
14
|
+
const resolvedReservedContracts = (version) => (Object.keys(FUNNEL_RESERVED_STEP_CONTRACTS).flatMap((identity) => {
|
|
15
|
+
const contract = resolveReservedStepContract(version, identity);
|
|
16
|
+
return contract ? [contract] : [];
|
|
17
|
+
}));
|
|
18
|
+
const exactReservedContracts = (version) => resolvedReservedContracts(version).filter((contract) => contract.match === 'exact');
|
|
19
|
+
const isExactReservedCandidate = (version, step) => {
|
|
20
|
+
return exactReservedContracts(version).some((contract) => {
|
|
21
|
+
return step.id === contract.id || step.name === contract.name;
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
const isNonEmptyString = (value) => {
|
|
25
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
26
|
+
};
|
|
27
|
+
const isKnownStepType = (version, value) => {
|
|
28
|
+
return typeof value === 'string' && resolveStepTypeContract(version, value) !== undefined;
|
|
29
|
+
};
|
|
30
|
+
const isKnownStepKind = (value) => {
|
|
31
|
+
return typeof value === 'string' && FUNNEL_STEP_KIND_SET.has(value);
|
|
32
|
+
};
|
|
33
|
+
const stepFile = (step) => {
|
|
34
|
+
return isNonEmptyString(step.filePath) ? step.filePath : MANIFEST_FILE;
|
|
35
|
+
};
|
|
36
|
+
const diagnostic = (input) => {
|
|
37
|
+
var _a;
|
|
38
|
+
return createFunnelContractDiagnostic({
|
|
39
|
+
code: input.code,
|
|
40
|
+
file: input.step ? stepFile(input.step) : MANIFEST_FILE,
|
|
41
|
+
stepId: input.step && isNonEmptyString(input.step.id) ? input.step.id : null,
|
|
42
|
+
expected: input.expected,
|
|
43
|
+
received: input.received,
|
|
44
|
+
reason: input.reason,
|
|
45
|
+
guide: (_a = input.guide) !== null && _a !== void 0 ? _a : AUTHORING_GUIDE,
|
|
46
|
+
repair: input.repair,
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
const boundaryDiagnostic = (input) => {
|
|
50
|
+
return diagnostic(Object.assign(Object.assign({}, input), { guide: input.code === 'FG-FLOW-002' ? FLOW_GUIDE : AUTHORING_GUIDE }));
|
|
51
|
+
};
|
|
52
|
+
const dedupeDiagnostics = (diagnostics) => {
|
|
53
|
+
const diagnosticByCanonicalValue = new Map();
|
|
54
|
+
for (const item of diagnostics) {
|
|
55
|
+
const key = JSON.stringify(item);
|
|
56
|
+
if (!diagnosticByCanonicalValue.has(key)) {
|
|
57
|
+
diagnosticByCanonicalValue.set(key, item);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return Array.from(diagnosticByCanonicalValue.values());
|
|
61
|
+
};
|
|
62
|
+
const validateVersion = (manifest, mode) => {
|
|
63
|
+
const version = manifest.stepContractVersion;
|
|
64
|
+
const received = version === undefined ? 'missing' : version;
|
|
65
|
+
if (mode === 'legacy-read') {
|
|
66
|
+
const readableVersion = version === undefined
|
|
67
|
+
? LEGACY_UNVERSIONED_STEP_CONTRACT_VERSION
|
|
68
|
+
: version;
|
|
69
|
+
if (typeof readableVersion === 'number'
|
|
70
|
+
&& Number.isInteger(readableVersion)
|
|
71
|
+
&& SUPPORTED_STEP_CONTRACT_VERSION_SET.has(readableVersion)) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
return [diagnostic({
|
|
75
|
+
code: 'FG-VERSION-001',
|
|
76
|
+
expected: SUPPORTED_STEP_CONTRACT_VERSIONS,
|
|
77
|
+
received,
|
|
78
|
+
reason: 'The manifest uses an unsupported step contract version.',
|
|
79
|
+
repair: 'Use a supported reader or migrate the manifest before reading it.',
|
|
80
|
+
})];
|
|
81
|
+
}
|
|
82
|
+
if (version === CURRENT_STEP_CONTRACT_VERSION) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
if (version === undefined
|
|
86
|
+
|| (typeof version === 'number' && SUPPORTED_STEP_CONTRACT_VERSION_SET.has(version))) {
|
|
87
|
+
return [diagnostic({
|
|
88
|
+
code: 'FG-VERSION-002',
|
|
89
|
+
expected: CURRENT_STEP_CONTRACT_VERSION,
|
|
90
|
+
received,
|
|
91
|
+
reason: `${mode} requires the current step contract version.`,
|
|
92
|
+
repair: `Migrate the manifest to step contract version ${CURRENT_STEP_CONTRACT_VERSION} before ${mode}.`,
|
|
93
|
+
})];
|
|
94
|
+
}
|
|
95
|
+
return [diagnostic({
|
|
96
|
+
code: 'FG-VERSION-001',
|
|
97
|
+
expected: CURRENT_STEP_CONTRACT_VERSION,
|
|
98
|
+
received,
|
|
99
|
+
reason: `${mode} cannot use an unsupported or future step contract version.`,
|
|
100
|
+
repair: 'Use tooling that supports this version or migrate the manifest to the current version.',
|
|
101
|
+
})];
|
|
102
|
+
};
|
|
103
|
+
const validateChoiceDescriptor = (version, step) => {
|
|
104
|
+
if (!isKnownStepType(version, step.type)) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
const contract = resolveStepTypeContract(version, step.type);
|
|
108
|
+
const choice = step.choice;
|
|
109
|
+
if (!contract.choice) {
|
|
110
|
+
if (choice === undefined) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
return [diagnostic({
|
|
114
|
+
code: 'FG-CHOICE-001',
|
|
115
|
+
step,
|
|
116
|
+
expected: 'omitted',
|
|
117
|
+
received: choice,
|
|
118
|
+
reason: 'Non-choice step types must omit choice metadata.',
|
|
119
|
+
repair: 'Remove the choice descriptor or assign the correct canonical choice step type.',
|
|
120
|
+
})];
|
|
121
|
+
}
|
|
122
|
+
const parsedChoice = parseFunnelChoiceDescriptor(step.type, choice);
|
|
123
|
+
if (!parsedChoice.ok) {
|
|
124
|
+
return [diagnostic({
|
|
125
|
+
code: 'FG-CHOICE-001',
|
|
126
|
+
step,
|
|
127
|
+
expected: {
|
|
128
|
+
answerKey: 'camelCase or snake_case safe identifier',
|
|
129
|
+
allowEmpty: 'optional boolean',
|
|
130
|
+
},
|
|
131
|
+
received: choice,
|
|
132
|
+
reason: 'Choice steps require an exact, stable choice descriptor.',
|
|
133
|
+
repair: 'Declare exactly answerKey and, when supported, an optional boolean allowEmpty.',
|
|
134
|
+
})];
|
|
135
|
+
}
|
|
136
|
+
return [];
|
|
137
|
+
};
|
|
138
|
+
const validateStepMetadata = (version, step) => {
|
|
139
|
+
const diagnostics = [];
|
|
140
|
+
const reservedCandidate = isExactReservedCandidate(version, step);
|
|
141
|
+
if (!isNonEmptyString(step.name) || !KEBAB_CASE_PATTERN.test(step.name)) {
|
|
142
|
+
diagnostics.push(diagnostic({
|
|
143
|
+
code: 'FG-STEP-002',
|
|
144
|
+
step,
|
|
145
|
+
expected: 'lowercase-kebab-case',
|
|
146
|
+
received: step.name,
|
|
147
|
+
reason: 'Every step requires a stable lowercase kebab-case analytics name.',
|
|
148
|
+
repair: 'Set name to a stable lowercase kebab-case value and keep it unchanged.',
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
if (!reservedCandidate && !isKnownStepType(version, step.type)) {
|
|
152
|
+
diagnostics.push(diagnostic({
|
|
153
|
+
code: 'FG-STEP-001',
|
|
154
|
+
step,
|
|
155
|
+
expected: FUNNEL_STEP_TYPES.filter((type) => resolveStepTypeContract(version, type)),
|
|
156
|
+
received: step.type,
|
|
157
|
+
reason: 'The step type is not part of the canonical taxonomy.',
|
|
158
|
+
repair: 'Replace type with the canonical type that matches the screen semantics.',
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
const receivedKind = step.kind;
|
|
162
|
+
if (!reservedCandidate && receivedKind === 'default') {
|
|
163
|
+
diagnostics.push(diagnostic({
|
|
164
|
+
code: 'FG-STEP-002',
|
|
165
|
+
step,
|
|
166
|
+
expected: 'omitted',
|
|
167
|
+
received: receivedKind,
|
|
168
|
+
reason: 'The legacy default kind is forbidden; ordinary step kinds are omitted.',
|
|
169
|
+
repair: 'Remove the kind field.',
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
else if (!reservedCandidate && receivedKind !== undefined && !isKnownStepKind(receivedKind)) {
|
|
173
|
+
diagnostics.push(diagnostic({
|
|
174
|
+
code: 'FG-STEP-001',
|
|
175
|
+
step,
|
|
176
|
+
expected: FUNNEL_STEP_KINDS,
|
|
177
|
+
received: receivedKind,
|
|
178
|
+
reason: 'The step kind is not part of the canonical taxonomy.',
|
|
179
|
+
repair: 'Use the canonical required kind for the step type, or omit it for ordinary types.',
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
else if (!reservedCandidate && isKnownStepType(version, step.type)) {
|
|
183
|
+
const contract = resolveStepTypeContract(version, step.type);
|
|
184
|
+
if (contract.requiredKind !== undefined && receivedKind !== contract.requiredKind) {
|
|
185
|
+
diagnostics.push(diagnostic({
|
|
186
|
+
code: 'FG-STEP-002',
|
|
187
|
+
step,
|
|
188
|
+
expected: contract.requiredKind,
|
|
189
|
+
received: receivedKind,
|
|
190
|
+
reason: 'The step kind does not match the canonical type contract.',
|
|
191
|
+
repair: `Set kind to ${contract.requiredKind}.`,
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
else if (contract.requiredKind === undefined && receivedKind !== undefined) {
|
|
195
|
+
diagnostics.push(diagnostic({
|
|
196
|
+
code: 'FG-STEP-002',
|
|
197
|
+
step,
|
|
198
|
+
expected: 'omitted',
|
|
199
|
+
received: receivedKind,
|
|
200
|
+
reason: 'Ordinary step types must omit kind.',
|
|
201
|
+
repair: 'Remove the kind field.',
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
diagnostics.push(...validateChoiceDescriptor(version, step));
|
|
206
|
+
return diagnostics;
|
|
207
|
+
};
|
|
208
|
+
const isReservedIdentityValue = (version, value) => {
|
|
209
|
+
return typeof value === 'string' && exactReservedContracts(version).some((contract) => {
|
|
210
|
+
return value === contract.id || value === contract.name;
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
const validateUniqueStepFields = (version, steps) => {
|
|
214
|
+
const diagnostics = [];
|
|
215
|
+
const fields = ['id', 'name', 'path', 'filePath', 'componentKey'];
|
|
216
|
+
for (const field of fields) {
|
|
217
|
+
const firstStepByValue = new Map();
|
|
218
|
+
for (const step of steps) {
|
|
219
|
+
const value = step[field];
|
|
220
|
+
if (!isNonEmptyString(value)) {
|
|
221
|
+
diagnostics.push(diagnostic({
|
|
222
|
+
code: 'FG-STEP-002',
|
|
223
|
+
step,
|
|
224
|
+
expected: `non-empty ${field}`,
|
|
225
|
+
received: value,
|
|
226
|
+
reason: `Every manifest step requires a non-empty ${field}.`,
|
|
227
|
+
repair: `Set a stable, unique ${field}.`,
|
|
228
|
+
}));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!firstStepByValue.has(value)) {
|
|
232
|
+
firstStepByValue.set(value, step);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if ((field === 'id' || field === 'name') && isReservedIdentityValue(version, value)) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
diagnostics.push(diagnostic({
|
|
239
|
+
code: 'FG-STEP-002',
|
|
240
|
+
step,
|
|
241
|
+
expected: `unique step ${field}`,
|
|
242
|
+
received: value,
|
|
243
|
+
reason: `Manifest step ${field} values must be unique.`,
|
|
244
|
+
repair: `Assign a unique ${field} without changing another step's stable identity.`,
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return diagnostics;
|
|
249
|
+
};
|
|
250
|
+
const exactReservedShape = (contract) => ({
|
|
251
|
+
id: contract.id,
|
|
252
|
+
name: contract.name,
|
|
253
|
+
type: contract.type,
|
|
254
|
+
kind: contract.kind,
|
|
255
|
+
});
|
|
256
|
+
const receivedReservedShape = (step) => ({
|
|
257
|
+
id: step.id,
|
|
258
|
+
name: step.name,
|
|
259
|
+
type: step.type,
|
|
260
|
+
kind: step.kind,
|
|
261
|
+
});
|
|
262
|
+
const matchesExactReservedContract = (step, contract) => {
|
|
263
|
+
return step.id === contract.id
|
|
264
|
+
&& step.name === contract.name
|
|
265
|
+
&& step.type === contract.type
|
|
266
|
+
&& step.kind === contract.kind;
|
|
267
|
+
};
|
|
268
|
+
const matchesPatternReservedContract = (step, contract) => {
|
|
269
|
+
return typeof step.id === 'string'
|
|
270
|
+
&& typeof step.name === 'string'
|
|
271
|
+
&& new RegExp(contract.idPattern).test(step.id)
|
|
272
|
+
&& new RegExp(contract.namePattern).test(step.name)
|
|
273
|
+
&& step.id === step.name
|
|
274
|
+
&& step.type === contract.type
|
|
275
|
+
&& step.kind === contract.kind;
|
|
276
|
+
};
|
|
277
|
+
const validateReservedSteps = (version, steps) => {
|
|
278
|
+
const diagnostics = [];
|
|
279
|
+
const exactContracts = exactReservedContracts(version);
|
|
280
|
+
const paywallVariantContract = resolveReservedStepContract(version, 'paywall-variant');
|
|
281
|
+
const primaryPaywallContract = resolveReservedStepContract(version, 'paywall');
|
|
282
|
+
const subscriptionTerminalContract = resolveReservedStepContract(version, 'subscription-started');
|
|
283
|
+
const subscriptionManagementContract = resolveReservedStepContract(version, 'manage-subscription');
|
|
284
|
+
for (const contract of exactContracts) {
|
|
285
|
+
const candidates = steps.filter((step) => step.id === contract.id || step.name === contract.name);
|
|
286
|
+
for (const step of candidates) {
|
|
287
|
+
if (matchesExactReservedContract(step, contract)) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
diagnostics.push(diagnostic({
|
|
291
|
+
code: 'FG-RESERVED-001',
|
|
292
|
+
step,
|
|
293
|
+
expected: exactReservedShape(contract),
|
|
294
|
+
received: receivedReservedShape(step),
|
|
295
|
+
reason: 'A reserved lifecycle identity must match its exact id, name, type, and kind contract.',
|
|
296
|
+
repair: 'Restore the complete reserved lifecycle step metadata pair.',
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
if (contract.unique && candidates.length > 1 && !contract.primary) {
|
|
300
|
+
diagnostics.push(diagnostic({
|
|
301
|
+
code: 'FG-RESERVED-001',
|
|
302
|
+
step: candidates[1],
|
|
303
|
+
expected: `one ${contract.id} reserved identity`,
|
|
304
|
+
received: candidates.length,
|
|
305
|
+
reason: 'Reserved lifecycle identities must be unique.',
|
|
306
|
+
repair: `Keep exactly one ${contract.id} step and give other screens semantic identities.`,
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (const step of steps) {
|
|
311
|
+
const isPaywallPrefixed = (typeof step.id === 'string' && step.id.startsWith('paywall-'))
|
|
312
|
+
|| (typeof step.name === 'string' && step.name.startsWith('paywall-'));
|
|
313
|
+
if (isPaywallPrefixed && !matchesPatternReservedContract(step, paywallVariantContract)) {
|
|
314
|
+
diagnostics.push(diagnostic({
|
|
315
|
+
code: 'FG-RESERVED-001',
|
|
316
|
+
step,
|
|
317
|
+
expected: paywallVariantContract.idPattern,
|
|
318
|
+
received: step.id === step.name ? step.id : receivedReservedShape(step),
|
|
319
|
+
reason: 'Paywall variants require identical stable id/name slugs and the paywall type/kind pair.',
|
|
320
|
+
repair: 'Use an identical lowercase paywall-* id/name with type paywall_offer and kind paywall.',
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
const isPrimaryIdentityCandidate = step.id === primaryPaywallContract.id
|
|
324
|
+
|| step.name === primaryPaywallContract.name;
|
|
325
|
+
const isPaywallVariant = matchesPatternReservedContract(step, paywallVariantContract);
|
|
326
|
+
if (step.type === 'paywall_offer'
|
|
327
|
+
&& !isPrimaryIdentityCandidate
|
|
328
|
+
&& !isPaywallVariant
|
|
329
|
+
&& !isPaywallPrefixed) {
|
|
330
|
+
const isScratchcard = step.id === 'step-4' || step.name === 'scratchpad-entry';
|
|
331
|
+
diagnostics.push(diagnostic({
|
|
332
|
+
code: isScratchcard ? 'FG-STEP-104' : 'FG-RESERVED-001',
|
|
333
|
+
step,
|
|
334
|
+
expected: isScratchcard
|
|
335
|
+
? 'progress_interstitial'
|
|
336
|
+
: 'id/name paywall or matching paywall-*',
|
|
337
|
+
received: isScratchcard ? step.type : receivedReservedShape(step),
|
|
338
|
+
reason: isScratchcard
|
|
339
|
+
? 'The known non-purchasable scratchcard is classified as a paywall offer.'
|
|
340
|
+
: 'A paywall offer must use a canonical reserved paywall identity.',
|
|
341
|
+
repair: isScratchcard
|
|
342
|
+
? 'Classify the scratchcard as progress_interstitial.'
|
|
343
|
+
: 'Rename the real offer to paywall or a matching paywall-* identity.',
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
if (step.type === subscriptionTerminalContract.type
|
|
347
|
+
&& step.id !== subscriptionTerminalContract.id
|
|
348
|
+
&& step.name !== subscriptionTerminalContract.name) {
|
|
349
|
+
diagnostics.push(diagnostic({
|
|
350
|
+
code: 'FG-RESERVED-001',
|
|
351
|
+
step,
|
|
352
|
+
expected: exactReservedShape(subscriptionTerminalContract),
|
|
353
|
+
received: receivedReservedShape(step),
|
|
354
|
+
reason: 'The completion terminal type requires the exact subscription-started identity.',
|
|
355
|
+
repair: 'Use the subscription-started reserved identity or choose a non-terminal handoff type.',
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
if (step.type === 'subscription_management'
|
|
359
|
+
&& step.id !== subscriptionManagementContract.id
|
|
360
|
+
&& step.name !== subscriptionManagementContract.name) {
|
|
361
|
+
diagnostics.push(diagnostic({
|
|
362
|
+
code: 'FG-RESERVED-001',
|
|
363
|
+
step,
|
|
364
|
+
expected: exactReservedShape(subscriptionManagementContract),
|
|
365
|
+
received: receivedReservedShape(step),
|
|
366
|
+
reason: 'The subscription management type requires the exact manage-subscription identity.',
|
|
367
|
+
repair: 'Restore the manage-subscription reserved identity.',
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const paywallOfferCount = steps.filter((step) => step.type === 'paywall_offer').length;
|
|
372
|
+
if (paywallOfferCount > 0) {
|
|
373
|
+
const primaryCount = steps.filter((step) => {
|
|
374
|
+
return step.id === primaryPaywallContract.id
|
|
375
|
+
|| step.name === primaryPaywallContract.name;
|
|
376
|
+
}).length;
|
|
377
|
+
if (primaryCount !== 1) {
|
|
378
|
+
diagnostics.push(diagnostic({
|
|
379
|
+
code: 'FG-RESERVED-002',
|
|
380
|
+
expected: 1,
|
|
381
|
+
received: primaryCount,
|
|
382
|
+
reason: 'Funnels with paywall offers require exactly one canonical primary paywall.',
|
|
383
|
+
repair: 'Keep exactly one id/name paywall step with type paywall_offer and kind paywall.',
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return diagnostics;
|
|
388
|
+
};
|
|
389
|
+
const isTerminalStep = (version, step) => {
|
|
390
|
+
var _a;
|
|
391
|
+
return Boolean(step && ((_a = resolveStepTypeContract(version, String(step.type))) === null || _a === void 0 ? void 0 : _a.terminal));
|
|
392
|
+
};
|
|
393
|
+
const validateFlow = (version, manifest, steps) => {
|
|
394
|
+
var _a;
|
|
395
|
+
const diagnostics = [];
|
|
396
|
+
const validStepIds = new Set(steps.map((step) => step.id));
|
|
397
|
+
const stepById = new Map(steps.map((step) => [step.id, step]));
|
|
398
|
+
const identityLabel = (value) => {
|
|
399
|
+
return isNonEmptyString(value) ? value : '<invalid>';
|
|
400
|
+
};
|
|
401
|
+
const reportMissingReference = (owner, received, step) => {
|
|
402
|
+
diagnostics.push(diagnostic({
|
|
403
|
+
code: 'FG-FLOW-002',
|
|
404
|
+
step,
|
|
405
|
+
expected: 'an existing manifest step id',
|
|
406
|
+
received,
|
|
407
|
+
reason: `${owner} references an unknown manifest step.`,
|
|
408
|
+
guide: FLOW_GUIDE,
|
|
409
|
+
repair: 'Point the reference at an existing step or add the missing step atomically.',
|
|
410
|
+
}));
|
|
411
|
+
};
|
|
412
|
+
const entryPointIds = new Set();
|
|
413
|
+
let defaultEntryPointCount = 0;
|
|
414
|
+
for (const entryPoint of manifest.entryPoints) {
|
|
415
|
+
const entryPointId = isNonEmptyString(entryPoint.id) ? entryPoint.id : null;
|
|
416
|
+
if (entryPointId === null || entryPointIds.has(entryPointId)) {
|
|
417
|
+
diagnostics.push(diagnostic({
|
|
418
|
+
code: 'FG-FLOW-002',
|
|
419
|
+
expected: 'unique non-empty entry point id',
|
|
420
|
+
received: entryPoint.id,
|
|
421
|
+
reason: 'Entry point identities must be present and unique.',
|
|
422
|
+
guide: FLOW_GUIDE,
|
|
423
|
+
repair: 'Assign each entry point a unique stable id.',
|
|
424
|
+
}));
|
|
425
|
+
}
|
|
426
|
+
if (entryPointId !== null) {
|
|
427
|
+
entryPointIds.add(entryPointId);
|
|
428
|
+
}
|
|
429
|
+
if (entryPoint.isDefault !== undefined && typeof entryPoint.isDefault !== 'boolean') {
|
|
430
|
+
diagnostics.push(diagnostic({
|
|
431
|
+
code: 'FG-FLOW-002',
|
|
432
|
+
expected: 'boolean or undefined',
|
|
433
|
+
received: entryPoint.isDefault,
|
|
434
|
+
reason: 'Entry point isDefault must be an explicit boolean when present.',
|
|
435
|
+
guide: FLOW_GUIDE,
|
|
436
|
+
repair: 'Set isDefault to true or false, or omit it.',
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
defaultEntryPointCount += entryPoint.isDefault === true ? 1 : 0;
|
|
440
|
+
if (!validStepIds.has(entryPoint.stepId)) {
|
|
441
|
+
reportMissingReference(`Entry point ${identityLabel(entryPoint.id)}`, entryPoint.stepId);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (defaultEntryPointCount > 1) {
|
|
445
|
+
diagnostics.push(diagnostic({
|
|
446
|
+
code: 'FG-FLOW-002',
|
|
447
|
+
expected: 'at most one default entry point',
|
|
448
|
+
received: defaultEntryPointCount,
|
|
449
|
+
reason: 'A manifest cannot declare multiple default entry points.',
|
|
450
|
+
guide: FLOW_GUIDE,
|
|
451
|
+
repair: 'Mark at most one entry point as default.',
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
for (const { sourceStepId, edges } of manifest.edgeGroups) {
|
|
455
|
+
const sourceStep = stepById.get(sourceStepId);
|
|
456
|
+
if (!sourceStep) {
|
|
457
|
+
reportMissingReference(`Edge source ${sourceStepId}`, sourceStepId);
|
|
458
|
+
}
|
|
459
|
+
const declaredEdges = edges !== null && edges !== void 0 ? edges : [];
|
|
460
|
+
for (const edge of declaredEdges) {
|
|
461
|
+
if (!validStepIds.has(edge.toStepId)) {
|
|
462
|
+
reportMissingReference(`Edge from ${sourceStepId}`, edge.toStepId, sourceStep);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (sourceStep
|
|
466
|
+
&& ((_a = resolveStepTypeContract(version, String(sourceStep.type))) === null || _a === void 0 ? void 0 : _a.terminal)
|
|
467
|
+
&& declaredEdges.length > 0) {
|
|
468
|
+
diagnostics.push(diagnostic({
|
|
469
|
+
code: 'FG-FLOW-002',
|
|
470
|
+
step: sourceStep,
|
|
471
|
+
expected: 'no outbound edges',
|
|
472
|
+
received: declaredEdges,
|
|
473
|
+
reason: 'Terminal contract step types cannot have outbound edges.',
|
|
474
|
+
guide: FLOW_GUIDE,
|
|
475
|
+
repair: 'Remove all outbound edges from the terminal step.',
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const branchIds = new Set();
|
|
480
|
+
for (const branch of manifest.branches) {
|
|
481
|
+
const branchId = isNonEmptyString(branch.id) ? branch.id : null;
|
|
482
|
+
if (branchId === null || branchIds.has(branchId)) {
|
|
483
|
+
diagnostics.push(diagnostic({
|
|
484
|
+
code: 'FG-FLOW-002',
|
|
485
|
+
expected: 'unique non-empty branch id',
|
|
486
|
+
received: branch.id,
|
|
487
|
+
reason: 'Branch identities must be present and unique.',
|
|
488
|
+
guide: FLOW_GUIDE,
|
|
489
|
+
repair: 'Assign each branch a unique stable id.',
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
if (branchId !== null) {
|
|
493
|
+
branchIds.add(branchId);
|
|
494
|
+
}
|
|
495
|
+
if (!isNonEmptyString(branch.name)) {
|
|
496
|
+
diagnostics.push(diagnostic({
|
|
497
|
+
code: 'FG-FLOW-002',
|
|
498
|
+
expected: 'non-empty branch name',
|
|
499
|
+
received: branch.name,
|
|
500
|
+
reason: 'Every branch requires a name.',
|
|
501
|
+
guide: FLOW_GUIDE,
|
|
502
|
+
repair: 'Set a descriptive branch name.',
|
|
503
|
+
}));
|
|
504
|
+
}
|
|
505
|
+
if (!validStepIds.has(branch.sourceStepId)) {
|
|
506
|
+
reportMissingReference(`Branch ${identityLabel(branch.id)} source`, branch.sourceStepId);
|
|
507
|
+
}
|
|
508
|
+
const branchSourceStep = stepById.get(branch.sourceStepId);
|
|
509
|
+
if (isTerminalStep(version, branchSourceStep)) {
|
|
510
|
+
diagnostics.push(diagnostic({
|
|
511
|
+
code: 'FG-FLOW-002',
|
|
512
|
+
step: branchSourceStep,
|
|
513
|
+
expected: 'no outbound routing',
|
|
514
|
+
received: { branchId: branch.id },
|
|
515
|
+
reason: 'Terminal contract step types cannot be branch sources.',
|
|
516
|
+
guide: FLOW_GUIDE,
|
|
517
|
+
repair: 'Move the branch source before the terminal step.',
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
if (branch.stepIds.length === 0) {
|
|
521
|
+
diagnostics.push(diagnostic({
|
|
522
|
+
code: 'FG-FLOW-002',
|
|
523
|
+
expected: 'at least one branch-owned step',
|
|
524
|
+
received: branch.stepIds,
|
|
525
|
+
reason: 'Branches must own at least one step.',
|
|
526
|
+
guide: FLOW_GUIDE,
|
|
527
|
+
repair: 'Add an existing step to the branch or remove the empty branch.',
|
|
528
|
+
}));
|
|
529
|
+
}
|
|
530
|
+
for (const branchStepId of branch.stepIds) {
|
|
531
|
+
if (!validStepIds.has(branchStepId)) {
|
|
532
|
+
reportMissingReference(`Branch ${identityLabel(branch.id)}`, branchStepId);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const experimentIds = new Set();
|
|
537
|
+
for (const experiment of manifest.experiments) {
|
|
538
|
+
const experimentId = isNonEmptyString(experiment.experimentId)
|
|
539
|
+
? experiment.experimentId
|
|
540
|
+
: null;
|
|
541
|
+
if (experimentId === null || experimentIds.has(experimentId)) {
|
|
542
|
+
diagnostics.push(diagnostic({
|
|
543
|
+
code: 'FG-FLOW-002',
|
|
544
|
+
step: stepById.get(experiment.stepId),
|
|
545
|
+
expected: 'unique non-empty experiment id',
|
|
546
|
+
received: experiment.experimentId,
|
|
547
|
+
reason: 'Experiment identities must be present and unique.',
|
|
548
|
+
guide: FLOW_GUIDE,
|
|
549
|
+
repair: 'Assign each experiment a unique stable id.',
|
|
550
|
+
}));
|
|
551
|
+
}
|
|
552
|
+
if (experimentId !== null) {
|
|
553
|
+
experimentIds.add(experimentId);
|
|
554
|
+
}
|
|
555
|
+
if (!validStepIds.has(experiment.stepId)) {
|
|
556
|
+
reportMissingReference(`Experiment ${identityLabel(experiment.experimentId)}`, experiment.stepId);
|
|
557
|
+
}
|
|
558
|
+
const experimentSourceStep = stepById.get(experiment.stepId);
|
|
559
|
+
if (isTerminalStep(version, experimentSourceStep) && experiment.variants.length > 0) {
|
|
560
|
+
diagnostics.push(diagnostic({
|
|
561
|
+
code: 'FG-FLOW-002',
|
|
562
|
+
step: experimentSourceStep,
|
|
563
|
+
expected: 'no outbound routing',
|
|
564
|
+
received: { experimentId: experiment.experimentId },
|
|
565
|
+
reason: 'Terminal contract step types cannot route through experiments.',
|
|
566
|
+
guide: FLOW_GUIDE,
|
|
567
|
+
repair: 'Move the experiment before the terminal step.',
|
|
568
|
+
}));
|
|
569
|
+
}
|
|
570
|
+
if (experiment.variants.length === 0) {
|
|
571
|
+
diagnostics.push(diagnostic({
|
|
572
|
+
code: 'FG-FLOW-002',
|
|
573
|
+
step: stepById.get(experiment.stepId),
|
|
574
|
+
expected: 'at least one experiment variant',
|
|
575
|
+
received: experiment.variants,
|
|
576
|
+
reason: 'Experiments must declare at least one route variant.',
|
|
577
|
+
guide: FLOW_GUIDE,
|
|
578
|
+
repair: 'Add a valid variant or remove the experiment.',
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
const variantKeys = new Set();
|
|
582
|
+
for (const variant of experiment.variants) {
|
|
583
|
+
const variantKey = isNonEmptyString(variant.variantKey) ? variant.variantKey : null;
|
|
584
|
+
if (variantKey === null || variantKeys.has(variantKey)) {
|
|
585
|
+
diagnostics.push(diagnostic({
|
|
586
|
+
code: 'FG-FLOW-002',
|
|
587
|
+
step: stepById.get(experiment.stepId),
|
|
588
|
+
expected: 'unique non-empty variant key within the experiment',
|
|
589
|
+
received: variant.variantKey,
|
|
590
|
+
reason: 'Experiment variant identities must be present and unique.',
|
|
591
|
+
guide: FLOW_GUIDE,
|
|
592
|
+
repair: 'Assign each variant a unique stable key.',
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
595
|
+
if (variantKey !== null) {
|
|
596
|
+
variantKeys.add(variantKey);
|
|
597
|
+
}
|
|
598
|
+
if (!validStepIds.has(variant.routeToStepId)) {
|
|
599
|
+
reportMissingReference(`Experiment variant ${identityLabel(experiment.experimentId)}:${identityLabel(variant.variantKey)}`, variant.routeToStepId, stepById.get(experiment.stepId));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return diagnostics;
|
|
604
|
+
};
|
|
605
|
+
export const validateFunnelManifestContract = (input, options) => {
|
|
606
|
+
try {
|
|
607
|
+
const normalized = normalizeFunnelManifestInput(input, boundaryDiagnostic);
|
|
608
|
+
if (!normalized.manifest) {
|
|
609
|
+
return normalized.diagnostics;
|
|
610
|
+
}
|
|
611
|
+
const manifest = normalized.manifest;
|
|
612
|
+
const steps = manifest.steps;
|
|
613
|
+
const declaredVersion = manifest.stepContractVersion;
|
|
614
|
+
const contractVersion = typeof declaredVersion === 'number'
|
|
615
|
+
&& SUPPORTED_STEP_CONTRACT_VERSION_SET.has(declaredVersion)
|
|
616
|
+
? declaredVersion
|
|
617
|
+
: declaredVersion === undefined
|
|
618
|
+
? LEGACY_UNVERSIONED_STEP_CONTRACT_VERSION
|
|
619
|
+
: CURRENT_STEP_CONTRACT_VERSION;
|
|
620
|
+
const diagnostics = [
|
|
621
|
+
...validateVersion(manifest, options.mode),
|
|
622
|
+
...normalized.diagnostics,
|
|
623
|
+
];
|
|
624
|
+
if (!Number.isInteger(manifest.templateArchitectureVersion)
|
|
625
|
+
|| typeof manifest.templateArchitectureVersion !== 'number'
|
|
626
|
+
|| manifest.templateArchitectureVersion < 1) {
|
|
627
|
+
diagnostics.push(diagnostic({
|
|
628
|
+
code: 'FG-STEP-002',
|
|
629
|
+
expected: 'positive integer templateArchitectureVersion',
|
|
630
|
+
received: manifest.templateArchitectureVersion,
|
|
631
|
+
reason: 'templateArchitectureVersion must be a positive integer.',
|
|
632
|
+
repair: 'Set templateArchitectureVersion to the supported positive architecture version.',
|
|
633
|
+
}));
|
|
634
|
+
}
|
|
635
|
+
if (manifest.declaredStepCount === 0) {
|
|
636
|
+
diagnostics.push(diagnostic({
|
|
637
|
+
code: 'FG-STEP-002',
|
|
638
|
+
expected: 'at least one manifest step',
|
|
639
|
+
received: steps,
|
|
640
|
+
reason: 'A funnel manifest must declare at least one step.',
|
|
641
|
+
repair: 'Add the first complete step before authoring or publishing.',
|
|
642
|
+
}));
|
|
643
|
+
}
|
|
644
|
+
for (const step of steps) {
|
|
645
|
+
diagnostics.push(...validateStepMetadata(contractVersion, step));
|
|
646
|
+
}
|
|
647
|
+
diagnostics.push(...validateUniqueStepFields(contractVersion, steps));
|
|
648
|
+
if (manifest.hasValidStepRecords) {
|
|
649
|
+
diagnostics.push(...validateReservedSteps(contractVersion, steps));
|
|
650
|
+
diagnostics.push(...validateFlow(contractVersion, manifest, steps));
|
|
651
|
+
}
|
|
652
|
+
return dedupeDiagnostics(diagnostics);
|
|
653
|
+
}
|
|
654
|
+
catch (_a) {
|
|
655
|
+
return [diagnostic({
|
|
656
|
+
code: 'FG-STEP-002',
|
|
657
|
+
expected: 'inspectable plain funnel manifest object',
|
|
658
|
+
received: 'uninspectable runtime input',
|
|
659
|
+
reason: 'The funnel manifest could not be inspected safely.',
|
|
660
|
+
repair: 'Provide a plain JSON-compatible funnel manifest without accessors or proxies.',
|
|
661
|
+
})];
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
const EXPERIMENTS_FILE = 'src/config/experiments.ts';
|
|
665
|
+
const DOCS_MANIFEST_FILE = '.funnelsgrove-docs.json';
|
|
666
|
+
const isObjectRecord = (value) => {
|
|
667
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
668
|
+
};
|
|
669
|
+
const readOwnDataProperty = (value, key) => {
|
|
670
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
671
|
+
if (!descriptor) {
|
|
672
|
+
return { status: 'missing', value: undefined };
|
|
673
|
+
}
|
|
674
|
+
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
675
|
+
return { status: 'accessor', value: undefined };
|
|
676
|
+
}
|
|
677
|
+
return { status: 'data', value: descriptor.value };
|
|
678
|
+
};
|
|
679
|
+
const runtimeValueType = (value) => {
|
|
680
|
+
try {
|
|
681
|
+
if (value === null) {
|
|
682
|
+
return 'null';
|
|
683
|
+
}
|
|
684
|
+
return Array.isArray(value) ? 'array' : typeof value;
|
|
685
|
+
}
|
|
686
|
+
catch (_a) {
|
|
687
|
+
return 'uninspectable';
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
const readBoundedDenseDataArrayUnsafe = (value) => {
|
|
691
|
+
if (!Array.isArray(value)) {
|
|
692
|
+
return {
|
|
693
|
+
valid: false,
|
|
694
|
+
summary: { state: 'not-array', receivedType: runtimeValueType(value) },
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const lengthProperty = readOwnDataProperty(value, 'length');
|
|
698
|
+
if (lengthProperty.status !== 'data'
|
|
699
|
+
|| typeof lengthProperty.value !== 'number'
|
|
700
|
+
|| !Number.isSafeInteger(lengthProperty.value)
|
|
701
|
+
|| lengthProperty.value < 0) {
|
|
702
|
+
return { valid: false, summary: { state: 'invalid-length' } };
|
|
703
|
+
}
|
|
704
|
+
if (lengthProperty.value > MAX_FUNNEL_MANIFEST_COLLECTION_ITEMS) {
|
|
705
|
+
return {
|
|
706
|
+
valid: false,
|
|
707
|
+
summary: { state: 'oversized', declaredLength: lengthProperty.value },
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
const values = [];
|
|
711
|
+
for (let index = 0; index < lengthProperty.value; index += 1) {
|
|
712
|
+
const item = readOwnDataProperty(value, String(index));
|
|
713
|
+
if (item.status === 'missing') {
|
|
714
|
+
return {
|
|
715
|
+
valid: false,
|
|
716
|
+
summary: { state: 'sparse', declaredLength: lengthProperty.value, index },
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (item.status === 'accessor') {
|
|
720
|
+
return {
|
|
721
|
+
valid: false,
|
|
722
|
+
summary: { state: 'accessor-index', declaredLength: lengthProperty.value, index },
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
values.push(item.value);
|
|
726
|
+
}
|
|
727
|
+
return { valid: true, values };
|
|
728
|
+
};
|
|
729
|
+
const readBoundedDenseDataArray = (value) => {
|
|
730
|
+
try {
|
|
731
|
+
return readBoundedDenseDataArrayUnsafe(value);
|
|
732
|
+
}
|
|
733
|
+
catch (_a) {
|
|
734
|
+
return { valid: false, summary: { state: 'uninspectable' } };
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
const MAX_FUNNEL_SNAPSHOT_RECORD_KEY_LENGTH = 256;
|
|
738
|
+
const readBoundedOwnDataRecord = (value) => {
|
|
739
|
+
try {
|
|
740
|
+
if (!isPlainDataRecord(value)) {
|
|
741
|
+
return {
|
|
742
|
+
valid: false,
|
|
743
|
+
summary: { state: 'not-record', receivedType: runtimeValueType(value) },
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
747
|
+
return { valid: false, summary: { state: 'symbol-property' } };
|
|
748
|
+
}
|
|
749
|
+
const keys = Object.getOwnPropertyNames(value);
|
|
750
|
+
if (keys.length > MAX_FUNNEL_MANIFEST_COLLECTION_ITEMS) {
|
|
751
|
+
return {
|
|
752
|
+
valid: false,
|
|
753
|
+
summary: { state: 'oversized', declaredKeyCount: keys.length },
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
const entries = new Map();
|
|
757
|
+
for (let keyIndex = 0; keyIndex < keys.length; keyIndex += 1) {
|
|
758
|
+
const key = keys[keyIndex];
|
|
759
|
+
if (key.length > MAX_FUNNEL_SNAPSHOT_RECORD_KEY_LENGTH) {
|
|
760
|
+
return {
|
|
761
|
+
valid: false,
|
|
762
|
+
summary: { state: 'key-too-long', keyIndex, keyLength: key.length },
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
const property = readOwnDataProperty(value, key);
|
|
766
|
+
if (property.status !== 'data') {
|
|
767
|
+
return {
|
|
768
|
+
valid: false,
|
|
769
|
+
summary: { state: 'accessor-property', keyIndex },
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
entries.set(key, property.value);
|
|
773
|
+
}
|
|
774
|
+
return { valid: true, entries };
|
|
775
|
+
}
|
|
776
|
+
catch (_a) {
|
|
777
|
+
return { valid: false, summary: { state: 'uninspectable' } };
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
const snapshotDiagnostic = (input) => {
|
|
781
|
+
var _a, _b, _c;
|
|
782
|
+
return createFunnelContractDiagnostic({
|
|
783
|
+
code: input.code,
|
|
784
|
+
file: (_a = input.file) !== null && _a !== void 0 ? _a : null,
|
|
785
|
+
stepId: (_b = input.stepId) !== null && _b !== void 0 ? _b : null,
|
|
786
|
+
expected: input.expected,
|
|
787
|
+
received: input.received,
|
|
788
|
+
reason: input.reason,
|
|
789
|
+
guide: (_c = input.guide) !== null && _c !== void 0 ? _c : AUTHORING_GUIDE,
|
|
790
|
+
repair: input.repair,
|
|
791
|
+
});
|
|
792
|
+
};
|
|
793
|
+
const validateSnapshotRegistry = (registeredComponentKeysInput, componentMetaByKeyInput, manifest) => {
|
|
794
|
+
var _a, _b, _c;
|
|
795
|
+
const diagnostics = [];
|
|
796
|
+
const registeredKeysRead = readBoundedDenseDataArray(registeredComponentKeysInput);
|
|
797
|
+
if (!registeredKeysRead.valid) {
|
|
798
|
+
return [snapshotDiagnostic({
|
|
799
|
+
code: 'FG-REGISTRY-001',
|
|
800
|
+
file: MANIFEST_FILE,
|
|
801
|
+
expected: 'a dense bounded array of nonblank component keys',
|
|
802
|
+
received: registeredKeysRead.summary,
|
|
803
|
+
reason: 'The registered component key collection is malformed.',
|
|
804
|
+
repair: 'Provide a dense bounded array containing only nonblank component key strings.',
|
|
805
|
+
})];
|
|
806
|
+
}
|
|
807
|
+
const invalidKeyIndex = registeredKeysRead.values.findIndex((key) => (!isNonEmptyString(key) || key.length > MAX_FUNNEL_SNAPSHOT_RECORD_KEY_LENGTH));
|
|
808
|
+
if (invalidKeyIndex !== -1) {
|
|
809
|
+
return [snapshotDiagnostic({
|
|
810
|
+
code: 'FG-REGISTRY-001',
|
|
811
|
+
file: MANIFEST_FILE,
|
|
812
|
+
expected: 'a dense bounded array of nonblank component keys',
|
|
813
|
+
received: Object.assign({ state: 'invalid-entry', index: invalidKeyIndex, receivedType: runtimeValueType(registeredKeysRead.values[invalidKeyIndex]) }, (typeof registeredKeysRead.values[invalidKeyIndex] === 'string'
|
|
814
|
+
? { keyLength: registeredKeysRead.values[invalidKeyIndex].length }
|
|
815
|
+
: {})),
|
|
816
|
+
reason: 'Registered component keys must be nonblank strings.',
|
|
817
|
+
repair: 'Replace the invalid registry entry with the exact manifest component key.',
|
|
818
|
+
})];
|
|
819
|
+
}
|
|
820
|
+
const registeredComponentKeys = registeredKeysRead.values;
|
|
821
|
+
const componentMetaRead = readBoundedOwnDataRecord(componentMetaByKeyInput);
|
|
822
|
+
if (!componentMetaRead.valid) {
|
|
823
|
+
return [snapshotDiagnostic({
|
|
824
|
+
code: 'FG-REGISTRY-001',
|
|
825
|
+
file: MANIFEST_FILE,
|
|
826
|
+
expected: 'a bounded own-data component metadata map',
|
|
827
|
+
received: componentMetaRead.summary,
|
|
828
|
+
reason: 'The component metadata map is malformed or exceeds snapshot limits.',
|
|
829
|
+
repair: 'Provide a bounded plain component metadata map without accessors or symbols.',
|
|
830
|
+
})];
|
|
831
|
+
}
|
|
832
|
+
const componentMetaByKey = componentMetaRead.entries;
|
|
833
|
+
const registrationCountByKey = new Map();
|
|
834
|
+
for (const key of registeredComponentKeys) {
|
|
835
|
+
if (typeof key !== 'string') {
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
registrationCountByKey.set(key, ((_a = registrationCountByKey.get(key)) !== null && _a !== void 0 ? _a : 0) + 1);
|
|
839
|
+
}
|
|
840
|
+
const manifestComponentKeys = new Set();
|
|
841
|
+
const manifestSteps = Array.isArray(manifest.steps) ? manifest.steps : [];
|
|
842
|
+
for (const step of manifestSteps) {
|
|
843
|
+
const componentKey = step.componentKey;
|
|
844
|
+
manifestComponentKeys.add(componentKey);
|
|
845
|
+
const registrationCount = (_b = registrationCountByKey.get(componentKey)) !== null && _b !== void 0 ? _b : 0;
|
|
846
|
+
if (componentKey.length > MAX_FUNNEL_SNAPSHOT_RECORD_KEY_LENGTH) {
|
|
847
|
+
diagnostics.push(snapshotDiagnostic({
|
|
848
|
+
code: 'FG-REGISTRY-001',
|
|
849
|
+
file: step.filePath,
|
|
850
|
+
stepId: step.id,
|
|
851
|
+
expected: `component key with at most ${MAX_FUNNEL_SNAPSHOT_RECORD_KEY_LENGTH} characters`,
|
|
852
|
+
received: { state: 'key-too-long', keyLength: componentKey.length },
|
|
853
|
+
reason: 'The manifest component key exceeds the bounded registry key length.',
|
|
854
|
+
repair: 'Use a stable bounded component key in the manifest and registry.',
|
|
855
|
+
}));
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
const hasMetadata = componentMetaByKey.has(componentKey)
|
|
859
|
+
&& isObjectRecord(componentMetaByKey.get(componentKey));
|
|
860
|
+
if (registrationCount === 1 && hasMetadata) {
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
diagnostics.push(snapshotDiagnostic({
|
|
864
|
+
code: 'FG-REGISTRY-001',
|
|
865
|
+
file: step.filePath,
|
|
866
|
+
stepId: step.id,
|
|
867
|
+
expected: { componentKey, registrationCount: 1, hasMetadata: true },
|
|
868
|
+
received: { componentKey, registrationCount, hasMetadata },
|
|
869
|
+
reason: 'Every manifest component key must have one registration and one metadata record.',
|
|
870
|
+
repair: 'Register the declared component exactly once and export its matching metadata.',
|
|
871
|
+
}));
|
|
872
|
+
}
|
|
873
|
+
const orphanKeys = new Set();
|
|
874
|
+
for (const key of registrationCountByKey.keys()) {
|
|
875
|
+
if (!manifestComponentKeys.has(key)) {
|
|
876
|
+
orphanKeys.add(key);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
for (const key of componentMetaByKey.keys()) {
|
|
880
|
+
if (!manifestComponentKeys.has(key)) {
|
|
881
|
+
orphanKeys.add(key);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
for (const componentKey of [...orphanKeys].sort()) {
|
|
885
|
+
diagnostics.push(snapshotDiagnostic({
|
|
886
|
+
code: 'FG-REGISTRY-001',
|
|
887
|
+
file: MANIFEST_FILE,
|
|
888
|
+
expected: { componentKey, declaredByManifest: true },
|
|
889
|
+
received: {
|
|
890
|
+
componentKey,
|
|
891
|
+
declaredByManifest: false,
|
|
892
|
+
registrationCount: (_c = registrationCountByKey.get(componentKey)) !== null && _c !== void 0 ? _c : 0,
|
|
893
|
+
hasMetadata: componentMetaByKey.has(componentKey),
|
|
894
|
+
},
|
|
895
|
+
reason: 'The component registry contains a key that no manifest step declares.',
|
|
896
|
+
repair: 'Remove the orphan registry entry or declare the complete manifest step atomically.',
|
|
897
|
+
}));
|
|
898
|
+
}
|
|
899
|
+
return diagnostics;
|
|
900
|
+
};
|
|
901
|
+
const validateSnapshotMetadataParity = (componentMetaByKeyInput, manifest) => {
|
|
902
|
+
const diagnostics = [];
|
|
903
|
+
const componentMetaRead = readBoundedOwnDataRecord(componentMetaByKeyInput);
|
|
904
|
+
if (!componentMetaRead.valid) {
|
|
905
|
+
return diagnostics;
|
|
906
|
+
}
|
|
907
|
+
const manifestSteps = Array.isArray(manifest.steps) ? manifest.steps : [];
|
|
908
|
+
for (const step of manifestSteps) {
|
|
909
|
+
const componentMeta = componentMetaRead.entries.get(step.componentKey);
|
|
910
|
+
if (!isObjectRecord(componentMeta)) {
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
diagnostics.push(...validateFunnelStepMetadataParity(step, componentMeta, { file: step.filePath }));
|
|
914
|
+
}
|
|
915
|
+
return diagnostics;
|
|
916
|
+
};
|
|
917
|
+
const choiceOptionSchema = (presentation) => {
|
|
918
|
+
return presentation === 'emoji'
|
|
919
|
+
? { required: ['id', 'label', 'emoji'], allowed: ['id', 'label', 'emoji'] }
|
|
920
|
+
: { required: ['id', 'label'], allowed: ['id', 'label', 'imageSrc', 'iconKey'] };
|
|
921
|
+
};
|
|
922
|
+
const validateChoiceOptionUnsafe = (option, presentation) => {
|
|
923
|
+
if (!isPlainDataRecord(option)) {
|
|
924
|
+
return { valid: false, fields: [], id: typeof option };
|
|
925
|
+
}
|
|
926
|
+
const schema = choiceOptionSchema(presentation);
|
|
927
|
+
const fields = Object.getOwnPropertyNames(option).sort();
|
|
928
|
+
const valueByField = new Map();
|
|
929
|
+
const hasOnlyDataFields = fields.every((field) => {
|
|
930
|
+
const property = readOwnDataProperty(option, field);
|
|
931
|
+
if (property.status !== 'data') {
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
valueByField.set(field, property.value);
|
|
935
|
+
return true;
|
|
936
|
+
});
|
|
937
|
+
const hasExactFields = hasOnlyDataFields
|
|
938
|
+
&& schema.required.every((field) => valueByField.has(field))
|
|
939
|
+
&& fields.every((field) => schema.allowed.includes(field));
|
|
940
|
+
const id = valueByField.get('id');
|
|
941
|
+
const label = valueByField.get('label');
|
|
942
|
+
const imageSrc = valueByField.get('imageSrc');
|
|
943
|
+
const iconKey = valueByField.get('iconKey');
|
|
944
|
+
const emoji = valueByField.get('emoji');
|
|
945
|
+
const hasValidIdentity = isNonEmptyString(id) && isNonEmptyString(label);
|
|
946
|
+
const hasValidPresentation = presentation === 'emoji'
|
|
947
|
+
? isNonEmptyString(emoji)
|
|
948
|
+
: (imageSrc === undefined || typeof imageSrc === 'string')
|
|
949
|
+
&& (iconKey === undefined || typeof iconKey === 'string');
|
|
950
|
+
return {
|
|
951
|
+
valid: hasExactFields && hasValidIdentity && hasValidPresentation,
|
|
952
|
+
fields,
|
|
953
|
+
id: typeof id === 'string' ? id : typeof id,
|
|
954
|
+
};
|
|
955
|
+
};
|
|
956
|
+
const validateChoiceOption = (option, presentation) => {
|
|
957
|
+
try {
|
|
958
|
+
return validateChoiceOptionUnsafe(option, presentation);
|
|
959
|
+
}
|
|
960
|
+
catch (_a) {
|
|
961
|
+
return { valid: false, fields: [], id: 'uninspectable' };
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
const validateSnapshotChoiceContent = (contentOptionsByStepIdInput, manifest) => {
|
|
965
|
+
const diagnostics = [];
|
|
966
|
+
const contentOptionsRead = readBoundedOwnDataRecord(contentOptionsByStepIdInput);
|
|
967
|
+
if (!contentOptionsRead.valid) {
|
|
968
|
+
return [snapshotDiagnostic({
|
|
969
|
+
code: 'FG-CONTENT-001',
|
|
970
|
+
file: MANIFEST_FILE,
|
|
971
|
+
expected: 'a bounded own-data choice content map',
|
|
972
|
+
received: contentOptionsRead.summary,
|
|
973
|
+
reason: 'The choice content map is malformed or exceeds snapshot limits.',
|
|
974
|
+
repair: 'Provide a bounded plain choice content map without accessors or symbols.',
|
|
975
|
+
})];
|
|
976
|
+
}
|
|
977
|
+
const contentOptionsByStepId = contentOptionsRead.entries;
|
|
978
|
+
const expectedChoiceStepIds = new Set();
|
|
979
|
+
const manifestSteps = Array.isArray(manifest.steps) ? manifest.steps : [];
|
|
980
|
+
for (const step of manifestSteps) {
|
|
981
|
+
const contract = resolveStepTypeContract(manifest.stepContractVersion, step.type);
|
|
982
|
+
if (!(contract === null || contract === void 0 ? void 0 : contract.choice)) {
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
expectedChoiceStepIds.add(step.id);
|
|
986
|
+
const ownsOptions = contentOptionsByStepId.has(step.id);
|
|
987
|
+
const options = ownsOptions ? contentOptionsByStepId.get(step.id) : undefined;
|
|
988
|
+
const optionsRead = readBoundedDenseDataArray(options);
|
|
989
|
+
if (!ownsOptions || !optionsRead.valid || optionsRead.values.length === 0) {
|
|
990
|
+
const received = !ownsOptions
|
|
991
|
+
? 'missing'
|
|
992
|
+
: !optionsRead.valid
|
|
993
|
+
? optionsRead.summary
|
|
994
|
+
: 'empty';
|
|
995
|
+
diagnostics.push(snapshotDiagnostic({
|
|
996
|
+
code: 'FG-CONTENT-001',
|
|
997
|
+
file: step.filePath,
|
|
998
|
+
stepId: step.id,
|
|
999
|
+
expected: ownsOptions && !optionsRead.valid
|
|
1000
|
+
? 'a dense bounded options array'
|
|
1001
|
+
: 'a non-empty options array owned by this choice step',
|
|
1002
|
+
received,
|
|
1003
|
+
reason: 'Choice steps require their own non-empty content option registry entry.',
|
|
1004
|
+
repair: 'Add a non-empty canonical option array under this exact stable step id.',
|
|
1005
|
+
}));
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
const seenIds = new Set();
|
|
1009
|
+
optionsRead.values.forEach((option, optionIndex) => {
|
|
1010
|
+
const result = validateChoiceOption(option, contract.choice.presentation);
|
|
1011
|
+
if (!result.valid) {
|
|
1012
|
+
diagnostics.push(snapshotDiagnostic({
|
|
1013
|
+
code: 'FG-CHOICE-002',
|
|
1014
|
+
file: step.filePath,
|
|
1015
|
+
stepId: step.id,
|
|
1016
|
+
expected: Object.assign({ optionIndex, presentation: contract.choice.presentation }, choiceOptionSchema(contract.choice.presentation)),
|
|
1017
|
+
received: { optionIndex, fields: result.fields, id: result.id },
|
|
1018
|
+
reason: 'The choice option does not match the exact presentation schema.',
|
|
1019
|
+
repair: contract.choice.presentation === 'emoji'
|
|
1020
|
+
? 'Use exactly id, label, and a nonblank emoji string.'
|
|
1021
|
+
: 'Use exactly id, label, and optional imageSrc or iconKey strings.',
|
|
1022
|
+
}));
|
|
1023
|
+
}
|
|
1024
|
+
if (isNonEmptyString(result.id)) {
|
|
1025
|
+
if (seenIds.has(result.id)) {
|
|
1026
|
+
diagnostics.push(snapshotDiagnostic({
|
|
1027
|
+
code: 'FG-CHOICE-002',
|
|
1028
|
+
file: step.filePath,
|
|
1029
|
+
stepId: step.id,
|
|
1030
|
+
expected: { optionIndex, id: 'unique within step' },
|
|
1031
|
+
received: { optionIndex, id: result.id },
|
|
1032
|
+
reason: 'Choice option IDs must be unique within their step.',
|
|
1033
|
+
repair: 'Assign a stable unique ID without using the display label as persisted state.',
|
|
1034
|
+
}));
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
seenIds.add(result.id);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
const orphanStepIds = [...contentOptionsByStepId.keys()]
|
|
1043
|
+
.filter((stepId) => !expectedChoiceStepIds.has(stepId))
|
|
1044
|
+
.sort();
|
|
1045
|
+
for (const stepId of orphanStepIds) {
|
|
1046
|
+
diagnostics.push(snapshotDiagnostic({
|
|
1047
|
+
code: 'FG-CONTENT-001',
|
|
1048
|
+
file: MANIFEST_FILE,
|
|
1049
|
+
stepId,
|
|
1050
|
+
expected: 'a manifest step with a canonical choice type',
|
|
1051
|
+
received: 'orphan content options entry',
|
|
1052
|
+
reason: 'Content options may only be registered for declared canonical choice steps.',
|
|
1053
|
+
repair: 'Remove the orphan content entry or declare the complete choice step contract.',
|
|
1054
|
+
}));
|
|
1055
|
+
}
|
|
1056
|
+
return diagnostics;
|
|
1057
|
+
};
|
|
1058
|
+
const compareCanonicalValues = (left, right, state) => {
|
|
1059
|
+
if (Object.is(left, right)) {
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
state.remainingValues -= 1;
|
|
1063
|
+
if (state.remainingValues < 0) {
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
1067
|
+
if (!Array.isArray(left) || !Array.isArray(right)) {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
const leftRead = readBoundedDenseDataArray(left);
|
|
1071
|
+
const rightRead = readBoundedDenseDataArray(right);
|
|
1072
|
+
if (!leftRead.valid
|
|
1073
|
+
|| !rightRead.valid
|
|
1074
|
+
|| leftRead.values.length !== rightRead.values.length) {
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
return leftRead.values.every((item, index) => (compareCanonicalValues(item, rightRead.values[index], state)));
|
|
1078
|
+
}
|
|
1079
|
+
if (!isPlainDataRecord(left) || !isPlainDataRecord(right)) {
|
|
1080
|
+
return false;
|
|
1081
|
+
}
|
|
1082
|
+
const existingRightValues = state.comparedObjectPairs.get(left);
|
|
1083
|
+
if (existingRightValues === null || existingRightValues === void 0 ? void 0 : existingRightValues.has(right)) {
|
|
1084
|
+
return true;
|
|
1085
|
+
}
|
|
1086
|
+
const comparedRightValues = existingRightValues !== null && existingRightValues !== void 0 ? existingRightValues : new WeakSet();
|
|
1087
|
+
comparedRightValues.add(right);
|
|
1088
|
+
state.comparedObjectPairs.set(left, comparedRightValues);
|
|
1089
|
+
const readComparableRecord = (record) => {
|
|
1090
|
+
const recordRead = readBoundedOwnDataRecord(record);
|
|
1091
|
+
if (!recordRead.valid) {
|
|
1092
|
+
return null;
|
|
1093
|
+
}
|
|
1094
|
+
const values = new Map();
|
|
1095
|
+
for (const [key, value] of recordRead.entries) {
|
|
1096
|
+
if (value !== undefined) {
|
|
1097
|
+
values.set(key, value);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return values;
|
|
1101
|
+
};
|
|
1102
|
+
const leftValues = readComparableRecord(left);
|
|
1103
|
+
const rightValues = readComparableRecord(right);
|
|
1104
|
+
if (!leftValues || !rightValues) {
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
const leftKeys = [...leftValues.keys()].sort();
|
|
1108
|
+
const rightKeys = [...rightValues.keys()].sort();
|
|
1109
|
+
if (leftKeys.length !== rightKeys.length
|
|
1110
|
+
|| leftKeys.some((key, index) => key !== rightKeys[index])) {
|
|
1111
|
+
return false;
|
|
1112
|
+
}
|
|
1113
|
+
return leftKeys.every((key) => (compareCanonicalValues(leftValues.get(key), rightValues.get(key), state)));
|
|
1114
|
+
};
|
|
1115
|
+
const canonicalDeepEqual = (left, right) => {
|
|
1116
|
+
return compareCanonicalValues(left, right, {
|
|
1117
|
+
remainingValues: MAX_FUNNEL_MANIFEST_COLLECTION_ITEMS,
|
|
1118
|
+
comparedObjectPairs: new WeakMap(),
|
|
1119
|
+
});
|
|
1120
|
+
};
|
|
1121
|
+
const summarizeFlowMismatchValue = (value, path) => {
|
|
1122
|
+
try {
|
|
1123
|
+
if (Array.isArray(value)) {
|
|
1124
|
+
const length = readOwnDataProperty(value, 'length');
|
|
1125
|
+
return Object.assign({ path, type: 'array' }, (length.status === 'data' && typeof length.value === 'number'
|
|
1126
|
+
? { count: length.value }
|
|
1127
|
+
: {}));
|
|
1128
|
+
}
|
|
1129
|
+
if (isPlainDataRecord(value)) {
|
|
1130
|
+
const recordRead = readBoundedOwnDataRecord(value);
|
|
1131
|
+
return Object.assign({ path, type: recordRead.valid ? 'object' : recordRead.summary.state }, (recordRead.valid ? { count: recordRead.entries.size } : {}));
|
|
1132
|
+
}
|
|
1133
|
+
return { path, type: runtimeValueType(value) };
|
|
1134
|
+
}
|
|
1135
|
+
catch (_a) {
|
|
1136
|
+
return { path, type: 'uninspectable' };
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
const validateSnapshotFlowParity = (edgesByStepIdInput, experimentsInput, manifest) => {
|
|
1140
|
+
const diagnostics = [];
|
|
1141
|
+
const manifestEdgesRead = readBoundedOwnDataRecord(manifest.edgesByStepId);
|
|
1142
|
+
const snapshotEdgesRead = readBoundedOwnDataRecord(edgesByStepIdInput);
|
|
1143
|
+
if (!manifestEdgesRead.valid || !snapshotEdgesRead.valid) {
|
|
1144
|
+
diagnostics.push(snapshotDiagnostic({
|
|
1145
|
+
code: 'FG-FLOW-001',
|
|
1146
|
+
file: MANIFEST_FILE,
|
|
1147
|
+
expected: manifestEdgesRead.valid
|
|
1148
|
+
? { path: 'edgesByStepId', type: 'object', count: manifestEdgesRead.entries.size }
|
|
1149
|
+
: { path: 'edgesByStepId', type: manifestEdgesRead.summary.state },
|
|
1150
|
+
received: snapshotEdgesRead.valid
|
|
1151
|
+
? { path: 'edgesByStepId', type: 'object', count: snapshotEdgesRead.entries.size }
|
|
1152
|
+
: { path: 'edgesByStepId', type: snapshotEdgesRead.summary.state },
|
|
1153
|
+
reason: 'The edge registry is malformed or exceeds bounded snapshot limits.',
|
|
1154
|
+
guide: FLOW_GUIDE,
|
|
1155
|
+
repair: 'Provide a bounded own-data edge registry that matches the manifest.',
|
|
1156
|
+
}));
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
const edgeKeys = new Set([
|
|
1160
|
+
...manifestEdgesRead.entries.keys(),
|
|
1161
|
+
...snapshotEdgesRead.entries.keys(),
|
|
1162
|
+
]);
|
|
1163
|
+
for (const stepId of [...edgeKeys].sort()) {
|
|
1164
|
+
const expected = manifestEdgesRead.entries.get(stepId);
|
|
1165
|
+
const received = snapshotEdgesRead.entries.get(stepId);
|
|
1166
|
+
if (canonicalDeepEqual(expected, received)) {
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
diagnostics.push(snapshotDiagnostic({
|
|
1170
|
+
code: 'FG-FLOW-001',
|
|
1171
|
+
file: MANIFEST_FILE,
|
|
1172
|
+
stepId,
|
|
1173
|
+
expected: summarizeFlowMismatchValue(expected, `edgesByStepId.${stepId}`),
|
|
1174
|
+
received: summarizeFlowMismatchValue(received, `edgesByStepId.${stepId}`),
|
|
1175
|
+
reason: 'Extracted edge metadata does not match the manifest edge array.',
|
|
1176
|
+
guide: FLOW_GUIDE,
|
|
1177
|
+
repair: 'Write the declared manifest and extracted edge registry as one candidate transaction.',
|
|
1178
|
+
}));
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
if (!canonicalDeepEqual(manifest.experiments, experimentsInput)) {
|
|
1182
|
+
diagnostics.push(snapshotDiagnostic({
|
|
1183
|
+
code: 'FG-FLOW-001',
|
|
1184
|
+
file: EXPERIMENTS_FILE,
|
|
1185
|
+
expected: summarizeFlowMismatchValue(manifest.experiments, 'experiments'),
|
|
1186
|
+
received: summarizeFlowMismatchValue(experimentsInput, 'experiments'),
|
|
1187
|
+
reason: 'Extracted experiment metadata does not match the manifest experiments.',
|
|
1188
|
+
guide: FLOW_GUIDE,
|
|
1189
|
+
repair: 'Write the manifest and experiment configuration as one candidate transaction.',
|
|
1190
|
+
}));
|
|
1191
|
+
}
|
|
1192
|
+
return diagnostics;
|
|
1193
|
+
};
|
|
1194
|
+
const validateSnapshotDocs = (docsInput) => {
|
|
1195
|
+
const diagnostics = [];
|
|
1196
|
+
const docsRead = readBoundedOwnDataRecord(docsInput);
|
|
1197
|
+
if (!docsRead.valid) {
|
|
1198
|
+
return [snapshotDiagnostic({
|
|
1199
|
+
code: 'FG-DOC-001',
|
|
1200
|
+
file: DOCS_MANIFEST_FILE,
|
|
1201
|
+
expected: 'a bounded own-data managed documentation status map',
|
|
1202
|
+
received: docsRead.summary,
|
|
1203
|
+
reason: 'The managed documentation status map is malformed or exceeds snapshot limits.',
|
|
1204
|
+
repair: 'Provide a bounded plain documentation status map without accessors or symbols.',
|
|
1205
|
+
})];
|
|
1206
|
+
}
|
|
1207
|
+
const docsValue = (field) => {
|
|
1208
|
+
return docsRead.entries.get(field);
|
|
1209
|
+
};
|
|
1210
|
+
const headers = [
|
|
1211
|
+
{
|
|
1212
|
+
field: 'schemaVersion',
|
|
1213
|
+
expected: 1,
|
|
1214
|
+
received: docsValue('schemaVersion'),
|
|
1215
|
+
valid: docsValue('schemaVersion') === 1,
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
field: 'bundleVersion',
|
|
1219
|
+
expected: 'nonblank bundle version',
|
|
1220
|
+
received: docsValue('bundleVersion'),
|
|
1221
|
+
// Exact bundle-version comparison belongs to the docs adapter because this
|
|
1222
|
+
// project snapshot intentionally carries no expected bundle version.
|
|
1223
|
+
valid: isNonEmptyString(docsValue('bundleVersion')),
|
|
1224
|
+
},
|
|
1225
|
+
{
|
|
1226
|
+
field: 'stepContractVersion',
|
|
1227
|
+
expected: CURRENT_STEP_CONTRACT_VERSION,
|
|
1228
|
+
received: docsValue('stepContractVersion'),
|
|
1229
|
+
valid: docsValue('stepContractVersion') === CURRENT_STEP_CONTRACT_VERSION,
|
|
1230
|
+
},
|
|
1231
|
+
{
|
|
1232
|
+
field: 'contractHash',
|
|
1233
|
+
expected: CURRENT_STEP_CONTRACT_HASH,
|
|
1234
|
+
received: docsValue('contractHash'),
|
|
1235
|
+
valid: docsValue('contractHash') === CURRENT_STEP_CONTRACT_HASH,
|
|
1236
|
+
},
|
|
1237
|
+
];
|
|
1238
|
+
for (const header of headers) {
|
|
1239
|
+
if (header.valid) {
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
diagnostics.push(snapshotDiagnostic({
|
|
1243
|
+
code: 'FG-DOC-001',
|
|
1244
|
+
file: DOCS_MANIFEST_FILE,
|
|
1245
|
+
expected: { field: header.field, value: header.expected },
|
|
1246
|
+
received: header.received,
|
|
1247
|
+
reason: `Managed documentation ${header.field} is stale or invalid.`,
|
|
1248
|
+
repair: 'Sync the current managed documentation bundle before authoring or publishing.',
|
|
1249
|
+
}));
|
|
1250
|
+
}
|
|
1251
|
+
const readManagedPaths = (field, code) => {
|
|
1252
|
+
const pathsRead = readBoundedDenseDataArray(docsValue(field));
|
|
1253
|
+
let invalidEntryIndex = -1;
|
|
1254
|
+
if (pathsRead.valid) {
|
|
1255
|
+
invalidEntryIndex = pathsRead.values.findIndex((path) => !isNonEmptyString(path));
|
|
1256
|
+
}
|
|
1257
|
+
if (!pathsRead.valid || invalidEntryIndex !== -1) {
|
|
1258
|
+
const received = !pathsRead.valid
|
|
1259
|
+
? Object.assign({ field }, pathsRead.summary) : {
|
|
1260
|
+
field,
|
|
1261
|
+
state: 'invalid-entry',
|
|
1262
|
+
index: invalidEntryIndex,
|
|
1263
|
+
receivedType: runtimeValueType(pathsRead.values[invalidEntryIndex]),
|
|
1264
|
+
};
|
|
1265
|
+
diagnostics.push(snapshotDiagnostic({
|
|
1266
|
+
code,
|
|
1267
|
+
file: DOCS_MANIFEST_FILE,
|
|
1268
|
+
expected: 'a dense bounded array of nonblank managed paths',
|
|
1269
|
+
received,
|
|
1270
|
+
reason: `Managed documentation ${field} must be a strict path array.`,
|
|
1271
|
+
repair: `Replace ${field} with a dense bounded array containing only nonblank path strings.`,
|
|
1272
|
+
}));
|
|
1273
|
+
return new Set();
|
|
1274
|
+
}
|
|
1275
|
+
return new Set(pathsRead.values);
|
|
1276
|
+
};
|
|
1277
|
+
const conflicts = readManagedPaths('conflicts', 'FG-DOC-002');
|
|
1278
|
+
const changed = new Set([...readManagedPaths('changed', 'FG-DOC-001')].filter((path) => !conflicts.has(path)));
|
|
1279
|
+
const missing = new Set([...readManagedPaths('missing', 'FG-DOC-001')].filter((path) => (!conflicts.has(path) && !changed.has(path))));
|
|
1280
|
+
const statuses = [
|
|
1281
|
+
{ code: 'FG-DOC-002', status: 'conflict', paths: [...conflicts].sort() },
|
|
1282
|
+
{ code: 'FG-DOC-001', status: 'changed', paths: [...changed].sort() },
|
|
1283
|
+
{ code: 'FG-DOC-001', status: 'missing', paths: [...missing].sort() },
|
|
1284
|
+
];
|
|
1285
|
+
for (const status of statuses) {
|
|
1286
|
+
for (const path of status.paths) {
|
|
1287
|
+
diagnostics.push(snapshotDiagnostic({
|
|
1288
|
+
code: status.code,
|
|
1289
|
+
file: path,
|
|
1290
|
+
expected: 'current managed documentation bytes',
|
|
1291
|
+
received: status.status,
|
|
1292
|
+
reason: status.status === 'conflict'
|
|
1293
|
+
? 'The managed documentation path has an unresolved local conflict.'
|
|
1294
|
+
: `The managed documentation path is ${status.status}.`,
|
|
1295
|
+
repair: status.status === 'conflict'
|
|
1296
|
+
? 'Resolve the managed-document conflict explicitly, preserving project guidance separately.'
|
|
1297
|
+
: 'Sync the current managed documentation bundle.',
|
|
1298
|
+
}));
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
return diagnostics;
|
|
1302
|
+
};
|
|
1303
|
+
const hasInspectableNormalizedManifest = (input) => {
|
|
1304
|
+
try {
|
|
1305
|
+
const normalized = normalizeFunnelManifestInput(input, boundaryDiagnostic);
|
|
1306
|
+
return normalized.manifest !== null && normalized.diagnostics.length === 0;
|
|
1307
|
+
}
|
|
1308
|
+
catch (_a) {
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
const runSnapshotValidationPhase = (phase) => {
|
|
1313
|
+
try {
|
|
1314
|
+
return phase.validate();
|
|
1315
|
+
}
|
|
1316
|
+
catch (_a) {
|
|
1317
|
+
return [snapshotDiagnostic({
|
|
1318
|
+
code: phase.code,
|
|
1319
|
+
expected: 'inspectable plain snapshot phase data',
|
|
1320
|
+
received: 'uninspectable runtime input',
|
|
1321
|
+
reason: phase.reason,
|
|
1322
|
+
guide: phase.guide,
|
|
1323
|
+
repair: phase.repair,
|
|
1324
|
+
})];
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
const inaccessibleSnapshotManifestDiagnostic = () => {
|
|
1328
|
+
return snapshotDiagnostic({
|
|
1329
|
+
code: 'FG-STEP-002',
|
|
1330
|
+
file: MANIFEST_FILE,
|
|
1331
|
+
expected: 'inspectable plain funnel manifest object',
|
|
1332
|
+
received: 'uninspectable runtime input',
|
|
1333
|
+
reason: 'The snapshot manifest could not be accessed safely.',
|
|
1334
|
+
repair: 'Provide a plain snapshot object with a plain manifest data property.',
|
|
1335
|
+
});
|
|
1336
|
+
};
|
|
1337
|
+
const SNAPSHOT_ROOT_FIELDS = [
|
|
1338
|
+
'manifest',
|
|
1339
|
+
'registeredComponentKeys',
|
|
1340
|
+
'componentMetaByKey',
|
|
1341
|
+
'contentOptionsByStepId',
|
|
1342
|
+
'edgesByStepId',
|
|
1343
|
+
'experiments',
|
|
1344
|
+
'docs',
|
|
1345
|
+
];
|
|
1346
|
+
const captureSnapshotRootData = (input) => {
|
|
1347
|
+
try {
|
|
1348
|
+
if (!isPlainDataRecord(input)) {
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
const values = new Map();
|
|
1352
|
+
const invalidFields = new Set();
|
|
1353
|
+
for (const field of SNAPSHOT_ROOT_FIELDS) {
|
|
1354
|
+
const property = readOwnDataProperty(input, field);
|
|
1355
|
+
if (property.status === 'data') {
|
|
1356
|
+
values.set(field, property.value);
|
|
1357
|
+
}
|
|
1358
|
+
else {
|
|
1359
|
+
invalidFields.add(field);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
const manifest = values.get('manifest');
|
|
1363
|
+
if (invalidFields.has('manifest') || !isPlainDataRecord(manifest)) {
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
1366
|
+
return {
|
|
1367
|
+
manifest: manifest,
|
|
1368
|
+
values,
|
|
1369
|
+
invalidFields,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
catch (_a) {
|
|
1373
|
+
return null;
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
export const validateFunnelStepContractSnapshot = (input, options) => {
|
|
1377
|
+
const captured = captureSnapshotRootData(input);
|
|
1378
|
+
if (captured === null) {
|
|
1379
|
+
return [inaccessibleSnapshotManifestDiagnostic()];
|
|
1380
|
+
}
|
|
1381
|
+
const diagnostics = validateFunnelManifestContract(captured.manifest, options);
|
|
1382
|
+
if (!hasInspectableNormalizedManifest(captured.manifest)) {
|
|
1383
|
+
return diagnostics;
|
|
1384
|
+
}
|
|
1385
|
+
const hasInvalidField = (...fields) => (fields.some((field) => captured.invalidFields.has(field)));
|
|
1386
|
+
const phases = [
|
|
1387
|
+
{
|
|
1388
|
+
code: 'FG-REGISTRY-001',
|
|
1389
|
+
reason: 'The component registry snapshot could not be inspected safely.',
|
|
1390
|
+
repair: 'Provide plain component registry arrays and metadata records.',
|
|
1391
|
+
validate: () => hasInvalidField('registeredComponentKeys', 'componentMetaByKey')
|
|
1392
|
+
? [snapshotDiagnostic({
|
|
1393
|
+
code: 'FG-REGISTRY-001',
|
|
1394
|
+
expected: 'own data properties for registry snapshot fields',
|
|
1395
|
+
received: 'missing or accessor-backed root field',
|
|
1396
|
+
reason: 'The component registry root fields must be own data properties.',
|
|
1397
|
+
repair: 'Provide own plain data properties for registry keys and component metadata.',
|
|
1398
|
+
})]
|
|
1399
|
+
: validateSnapshotRegistry(captured.values.get('registeredComponentKeys'), captured.values.get('componentMetaByKey'), captured.manifest),
|
|
1400
|
+
},
|
|
1401
|
+
{
|
|
1402
|
+
code: 'FG-PARITY-001',
|
|
1403
|
+
reason: 'The component metadata snapshot could not be inspected safely for parity.',
|
|
1404
|
+
repair: 'Provide plain component metadata records that can be compared with the manifest.',
|
|
1405
|
+
validate: () => hasInvalidField('componentMetaByKey')
|
|
1406
|
+
? []
|
|
1407
|
+
: validateSnapshotMetadataParity(captured.values.get('componentMetaByKey'), captured.manifest),
|
|
1408
|
+
},
|
|
1409
|
+
{
|
|
1410
|
+
code: 'FG-CONTENT-001',
|
|
1411
|
+
reason: 'The choice content snapshot could not be inspected safely.',
|
|
1412
|
+
repair: 'Provide plain choice content arrays and option records.',
|
|
1413
|
+
validate: () => hasInvalidField('contentOptionsByStepId')
|
|
1414
|
+
? [snapshotDiagnostic({
|
|
1415
|
+
code: 'FG-CONTENT-001',
|
|
1416
|
+
expected: 'own data property for contentOptionsByStepId',
|
|
1417
|
+
received: 'missing or accessor-backed root field',
|
|
1418
|
+
reason: 'The choice content root field must be an own data property.',
|
|
1419
|
+
repair: 'Provide an own plain data property for choice content.',
|
|
1420
|
+
})]
|
|
1421
|
+
: validateSnapshotChoiceContent(captured.values.get('contentOptionsByStepId'), captured.manifest),
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
code: 'FG-FLOW-001',
|
|
1425
|
+
guide: FLOW_GUIDE,
|
|
1426
|
+
reason: 'The extracted flow snapshot could not be inspected safely.',
|
|
1427
|
+
repair: 'Provide plain edge and experiment collections that match the manifest.',
|
|
1428
|
+
validate: () => hasInvalidField('edgesByStepId', 'experiments')
|
|
1429
|
+
? [snapshotDiagnostic({
|
|
1430
|
+
code: 'FG-FLOW-001',
|
|
1431
|
+
expected: 'own data properties for flow snapshot fields',
|
|
1432
|
+
received: 'missing or accessor-backed root field',
|
|
1433
|
+
reason: 'The flow root fields must be own data properties.',
|
|
1434
|
+
guide: FLOW_GUIDE,
|
|
1435
|
+
repair: 'Provide own plain data properties for edges and experiments.',
|
|
1436
|
+
})]
|
|
1437
|
+
: validateSnapshotFlowParity(captured.values.get('edgesByStepId'), captured.values.get('experiments'), captured.manifest),
|
|
1438
|
+
},
|
|
1439
|
+
{
|
|
1440
|
+
code: 'FG-DOC-001',
|
|
1441
|
+
reason: 'The managed documentation snapshot could not be inspected safely.',
|
|
1442
|
+
repair: 'Provide a plain managed documentation status object.',
|
|
1443
|
+
validate: () => hasInvalidField('docs')
|
|
1444
|
+
? [snapshotDiagnostic({
|
|
1445
|
+
code: 'FG-DOC-001',
|
|
1446
|
+
expected: 'own data property for docs',
|
|
1447
|
+
received: 'missing or accessor-backed root field',
|
|
1448
|
+
reason: 'The docs root field must be an own data property.',
|
|
1449
|
+
repair: 'Provide an own plain data property for managed documentation status.',
|
|
1450
|
+
})]
|
|
1451
|
+
: validateSnapshotDocs(captured.values.get('docs')),
|
|
1452
|
+
},
|
|
1453
|
+
];
|
|
1454
|
+
for (const phase of phases) {
|
|
1455
|
+
diagnostics.push(...runSnapshotValidationPhase(phase));
|
|
1456
|
+
}
|
|
1457
|
+
return diagnostics;
|
|
1458
|
+
};
|