@enricai/barnacle 1.12.12 → 1.12.13

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.
@@ -26,12 +26,14 @@ exports.buildKnownFieldValues = buildKnownFieldValues;
26
26
  exports.extractStepPersonaValue = extractStepPersonaValue;
27
27
  exports.deriveFillLabelField = deriveFillLabelField;
28
28
  exports.harvestPersonaBindings = harvestPersonaBindings;
29
+ exports.resolveCompositePersonaFields = resolveCompositePersonaFields;
29
30
  exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
30
31
  exports.selectPayloadAction = selectPayloadAction;
31
32
  exports.selectReturnAction = selectReturnAction;
32
33
  exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
33
34
  exports.extractEntryUrlParams = extractEntryUrlParams;
34
35
  exports.selectPrimaryGraphQLOperation = selectPrimaryGraphQLOperation;
36
+ exports.firstEndpointCapture = firstEndpointCapture;
35
37
  exports.firstEndpointPath = firstEndpointPath;
36
38
  exports.resolveManifestActionSequence = resolveManifestActionSequence;
37
39
  exports.extractActionSequence = extractActionSequence;
@@ -42,16 +44,20 @@ exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
42
44
  exports.buildSelectOptionResolutions = buildSelectOptionResolutions;
43
45
  exports.walkSetCookiePairs = walkSetCookiePairs;
44
46
  exports.indexStateValues = indexStateValues;
47
+ exports.sanitizeFixtureIdentifier = sanitizeFixtureIdentifier;
45
48
  exports.compileActionSteps = compileActionSteps;
46
49
  exports.collectHeaderBindings = collectHeaderBindings;
47
50
  exports.deriveProducerBoundaryBindings = deriveProducerBoundaryBindings;
48
51
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
52
+ exports.buildContractChecklist = buildContractChecklist;
49
53
  exports.emitContractTs = emitContractTs;
54
+ exports.assertRequiredUrlFieldsReferenced = assertRequiredUrlFieldsReferenced;
50
55
  exports.emitConfigManifest = emitConfigManifest;
51
56
  exports.emitBrowserFlowTs = emitBrowserFlowTs;
52
57
  exports.emitIndexTs = emitIndexTs;
53
58
  const node_fs_1 = require("node:fs");
54
59
  const node_path_1 = require("node:path");
60
+ const ats_field_vocabulary_1 = require("../lib/ats-field-vocabulary");
55
61
  const errors_1 = require("../lib/errors");
56
62
  const logging_1 = require("../lib/logging");
57
63
  const plugin_api_version_1 = require("../plugins/plugin-api-version");
@@ -348,7 +354,7 @@ function deriveFillLabelField(instruction) {
348
354
  const label = /\b(?:fill(?:\s+in)?|enter|type)\s+(?:in\s+)?the\s+(.+?)\s+field\b/i.exec(instruction)?.[1];
349
355
  if (label === undefined)
350
356
  return null;
351
- return fieldNameToPascalCase(label, null);
357
+ return (0, ats_field_vocabulary_1.resolveCanonicalAtsFieldName)(label) ?? fieldNameToPascalCase(label, null);
352
358
  }
353
359
  /**
354
360
  * Builds the map from a recon persona VALUE (as it appears in the captured
@@ -389,6 +395,71 @@ function harvestPersonaBindings(flowSteps, vocabulary, env) {
389
395
  }
390
396
  return bindings;
391
397
  }
398
+ /**
399
+ * Detects a flow step whose quoted VALUE is the space-joined concatenation of
400
+ * two already-known field values (either order), e.g. a signature step whose
401
+ * label the vocabulary doesn't recognize but whose value is `${FirstName}
402
+ * ${LastName}`. {@link resolveStepPayloadField} only ever resolves a SINGLE
403
+ * field per step, so a composite value like this falls through its label
404
+ * match entirely and the persona literal would otherwise survive emission.
405
+ *
406
+ * @returns the two known fields (in the order they appear in the value), or
407
+ * null when the step's value is not such a concatenation
408
+ */
409
+ function resolveCompositePersonaFields(instruction, knownFieldValues) {
410
+ const spans = findQuoteSpans(instruction);
411
+ if (spans.length === 0)
412
+ return null;
413
+ const value = pickValueSpan(instruction, spans).value;
414
+ if (value.length === 0)
415
+ return null;
416
+ for (const [fieldA, valueA] of knownFieldValues) {
417
+ for (const [fieldB, valueB] of knownFieldValues) {
418
+ if (fieldA === fieldB)
419
+ continue;
420
+ if (value === `${valueA} ${valueB}`)
421
+ return { fieldA, fieldB };
422
+ }
423
+ }
424
+ return null;
425
+ }
426
+ /**
427
+ * Every persona literal a generated flow could leak: each single known field
428
+ * value, plus both concatenation orders of every distinct pair — the same
429
+ * composite shape {@link resolveCompositePersonaFields} splices. Feeds
430
+ * {@link assertNoLeakedPersonaConstant}'s final safety-net scan so a splice
431
+ * miss (single OR composite) is caught regardless of which matching path
432
+ * should have handled it.
433
+ */
434
+ function allPersonaLiterals(knownFieldValues) {
435
+ const entries = [...knownFieldValues.entries()];
436
+ const literals = entries.map(([, value]) => value);
437
+ for (const [fieldA, valueA] of entries) {
438
+ for (const [fieldB, valueB] of entries) {
439
+ if (fieldA === fieldB)
440
+ continue;
441
+ literals.push(`${valueA} ${valueB}`);
442
+ }
443
+ }
444
+ return literals.filter((value) => value.length > 0);
445
+ }
446
+ /**
447
+ * Final safety net: throws if any known persona constant — single field or
448
+ * composite concatenation — still appears verbatim as a quoted literal in the
449
+ * fully-built flow code. Every splice path above (vocabulary match, derived
450
+ * label, composite match) is a heuristic; this is the loud failure that
451
+ * catches whatever heuristic missed rather than shipping the recon identity's
452
+ * own data as a frozen literal in every caller's submission.
453
+ *
454
+ * @throws when a persona literal survives emission
455
+ */
456
+ function assertNoLeakedPersonaConstant(code, knownFieldValues) {
457
+ for (const value of allPersonaLiterals(knownFieldValues)) {
458
+ if (code.includes(`'${value}'`)) {
459
+ throw new Error(`recon-generate: persona constant '${value}' survived emission — a flow step failed to splice its known field value to payload.<field>`);
460
+ }
461
+ }
462
+ }
392
463
  /**
393
464
  * How deep to infer before collapsing to z.unknown(). Deep enough to reach the
394
465
  * fields that carry meaning on real inventory APIs — a listing's price
@@ -703,9 +774,6 @@ function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null)
703
774
  function isGraphQL(captures) {
704
775
  return captures.some((c) => c.operationName !== null);
705
776
  }
706
- function firstSuccessfulReplayBody(replays) {
707
- return replays.find((r) => r.success)?.replayBody ?? null;
708
- }
709
777
  function firstGraphQLQuery(captures) {
710
778
  return captures.find((c) => c.query)?.query ?? null;
711
779
  }
@@ -789,11 +857,17 @@ function selectPrimaryGraphQLOperation(captures, flowSteps, vocabulary, env = pr
789
857
  })();
790
858
  return { capture: winner.capture, endpointPath };
791
859
  }
792
- function firstEndpointPath(captures) {
860
+ /**
861
+ * Resolves the same primary-endpoint capture that {@link firstEndpointPath}
862
+ * derives a path string from, so non-GraphQL flows can also read that
863
+ * capture's own `.responseBody` instead of an array-order-first replay.
864
+ */
865
+ function firstEndpointCapture(captures) {
793
866
  const nonGetCaptures = captures.filter((c) => c.method !== "GET");
794
867
  for (const c of nonGetCaptures) {
795
868
  try {
796
- return new URL(c.url).pathname;
869
+ new URL(c.url);
870
+ return c;
797
871
  }
798
872
  catch {
799
873
  // skip
@@ -801,13 +875,23 @@ function firstEndpointPath(captures) {
801
875
  }
802
876
  for (const c of captures) {
803
877
  try {
804
- return new URL(c.url).pathname;
878
+ new URL(c.url);
879
+ return c;
805
880
  }
806
881
  catch {
807
882
  // skip
808
883
  }
809
884
  }
810
- return "/api/search";
885
+ return null;
886
+ }
887
+ function firstEndpointPath(captures) {
888
+ try {
889
+ const capture = firstEndpointCapture(captures);
890
+ return capture ? new URL(capture.url).pathname : "/api/search";
891
+ }
892
+ catch {
893
+ return "/api/search";
894
+ }
811
895
  }
812
896
  /**
813
897
  * Builds the compiled submit-pattern predicate. A flow-declared regex that
@@ -1344,6 +1428,12 @@ function looksLikeSectionFieldsArray(arr, formSchema) {
1344
1428
  }
1345
1429
  function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap, formSchema) {
1346
1430
  let currentPrefix = null;
1431
+ // Repeated/indexed headings (e.g. "Reference #1") must keep suppressing
1432
+ // canonicalization to avoid colliding a nested entity's field with the
1433
+ // applicant's own top-level field. Plain grouping headings with no
1434
+ // repetition marker (e.g. "CONTACT INFORMATION") still describe the
1435
+ // applicant, so canonicalization must still apply under them.
1436
+ let currentPrefixIsRepeated = false;
1347
1437
  const usedNames = new Set([...fieldNameMap.values()]);
1348
1438
  // First field-name key is the machine code (preferred, PascalCased directly);
1349
1439
  // any later key is a human label (subject to the section-heading heuristic).
@@ -1358,8 +1448,9 @@ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap, formSchem
1358
1448
  const name = labelKey !== undefined ? obj[labelKey] : obj[codeKey ?? ""];
1359
1449
  let semantic = null;
1360
1450
  if (typeof sourceCode === "string" && sourceCode.trim().length > 0) {
1361
- semantic = sourceCodeToPascalCase(sourceCode);
1451
+ semantic = (0, ats_field_vocabulary_1.resolveCanonicalAtsFieldName)(sourceCode) ?? sourceCodeToPascalCase(sourceCode);
1362
1452
  currentPrefix = null;
1453
+ currentPrefixIsRepeated = false;
1363
1454
  }
1364
1455
  else if (typeof name === "string" && name.trim().length > 0 && name.length < 250) {
1365
1456
  const hasNoSourceCode = typeof sourceCode !== "string" || sourceCode.trim().length === 0;
@@ -1375,10 +1466,23 @@ function assignFieldNamesFromArray(arr, fieldNameMap, fieldOptionsMap, formSchem
1375
1466
  const headingPrefix = fieldNameToPascalCase(name, null);
1376
1467
  if (headingPrefix !== null) {
1377
1468
  currentPrefix = headingPrefix;
1469
+ // Only a repeated/indexed heading (e.g. "Reference #1", "Employer 2")
1470
+ // marks a nested sub-entity whose fields must stay prefixed to avoid
1471
+ // colliding with the applicant's own top-level field name. A trailing
1472
+ // 1-2 digit index, standing on its own word boundary, is a repetition
1473
+ // marker; a longer digit run (e.g. a year in "EMPLOYMENT HISTORY
1474
+ // 2024") is not, and must still canonicalize underneath it.
1475
+ currentPrefixIsRepeated = /\b\d{1,2}\s*$/.test(name) || name.includes("#");
1378
1476
  }
1379
1477
  continue;
1380
1478
  }
1381
1479
  semantic = fieldNameToPascalCase(name, currentPrefix);
1480
+ if (!currentPrefixIsRepeated) {
1481
+ const canonical = (0, ats_field_vocabulary_1.resolveCanonicalAtsFieldName)(name);
1482
+ if (canonical !== null) {
1483
+ semantic = canonical;
1484
+ }
1485
+ }
1382
1486
  }
1383
1487
  if (semantic !== null && !fieldNameMap.has(fieldId)) {
1384
1488
  let unique = semantic;
@@ -2124,6 +2228,19 @@ function indexStateValues(captures, shieldedUuids = new Set(), actionCaptureIndi
2124
2228
  function isValidJsIdentifier(s) {
2125
2229
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s);
2126
2230
  }
2231
+ /** Derives a valid camelCase identifier from a fixture filename (e.g.
2232
+ * "10219132.json" -> "fixture10219132", "acme-metrics.config.json" ->
2233
+ * "acmeMetricsConfig") for use in generated `loadFixture` const lines. */
2234
+ function sanitizeFixtureIdentifier(filename) {
2235
+ const camelCased = filename
2236
+ .replace(/\.json$/, "")
2237
+ .split(/[^A-Za-z0-9_$]+/)
2238
+ .filter(Boolean)
2239
+ .map((part, i) => (i === 0 ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`))
2240
+ .join("");
2241
+ const stripped = camelCased.replace(/[^A-Za-z0-9_$]/g, "");
2242
+ return isValidJsIdentifier(stripped) ? stripped : `fixture${stripped}`;
2243
+ }
2127
2244
  /**
2128
2245
  * Converts a path like ["Auth","Token"] to a JS access expression ".Auth.Token".
2129
2246
  * Identifier segments use dot access; numeric / non-identifier segments use
@@ -3465,8 +3582,46 @@ function renderGqlVariablesExpr(variables, payloadFieldNames) {
3465
3582
  });
3466
3583
  return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
3467
3584
  }
3585
+ /**
3586
+ * Same review-checklist items the pre-move contract.ts header used to embed,
3587
+ * now surfaced on recon-generate's own stdout instead of the shipped file —
3588
+ * call with the exact same opts passed to {@link emitContractTs} so the two
3589
+ * can never drift out of sync.
3590
+ */
3591
+ function buildContractChecklist(opts) {
3592
+ const { pascal, gql, omitExecuteHttp, multiStepBody } = opts;
3593
+ const queryChecklistLine = !omitExecuteHttp && gql
3594
+ ? `Trim UI-only fields from ${pascal.toUpperCase()}_QUERY (keep only fields you need)`
3595
+ : "";
3596
+ // Multi-step flows validate each call against its own per-call inferred
3597
+ // schema (emitMultiStepExecuteHttp) — narrowing ResponseSchema only changes
3598
+ // what executeHttp promises ITS OWN caller, never a per-call validator, so
3599
+ // the checklist item must say that explicitly. Single-endpoint plugins have
3600
+ // exactly one call, so the client schema and that call's validator are the
3601
+ // same schema and the shorter wording stays accurate. Browser-flow-only
3602
+ // plugins have no executeHttp at all, so ResponseSchema is only ever the
3603
+ // browser flow's own return-value contract.
3604
+ const narrowSchemaChecklistLine = omitExecuteHttp
3605
+ ? `Narrow ${pascal}ResponseSchema to match what the browser flow should promise ITS CALLER — this flow could not synthesize a trustworthy executeHttp (a required value from the captured sequence never resolved), so it ships browser-only`
3606
+ : multiStepBody
3607
+ ? `Narrow ${pascal}ResponseSchema to match what executeHttp should promise ITS CALLER — this is the plugin's own return-value contract, not a per-call validator (each call in the flow is already checked against its own inferred schema)`
3608
+ : `Narrow ${pascal}ResponseSchema to match the real response shape`;
3609
+ const baseHeadersChecklistLine = omitExecuteHttp
3610
+ ? ""
3611
+ : "Verify BASE_HEADERS — remove any that aren't load-bearing";
3612
+ const outOfTreeChecklistLine = omitExecuteHttp
3613
+ ? "Out-of-tree: `pnpm add zod` — this file imports it directly, and a strict node_modules layout (pnpm) won't resolve it as a transitive dep of @enricai/barnacle alone"
3614
+ : "Out-of-tree: `pnpm add bottleneck zod` — this file imports both directly, and a strict node_modules layout (pnpm) won't resolve them as transitive deps of @enricai/barnacle alone";
3615
+ return [
3616
+ queryChecklistLine,
3617
+ narrowSchemaChecklistLine,
3618
+ `Adjust ${pascal}PayloadSchema to your actual request parameters`,
3619
+ baseHeadersChecklistLine,
3620
+ outOfTreeChecklistLine,
3621
+ ].filter((line) => line !== "");
3622
+ }
3468
3623
  function emitContractTs(opts) {
3469
- const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, auxFiles, multiStepBody, omitExecuteHttp = false, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], } = opts;
3624
+ const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, hasRateLimitProbeData = false, responseBody, gql, gqlQuery, endpointPath, gqlOperationName, gqlVariables, auxFiles, multiStepBody, omitExecuteHttp = false, isSubmissionFlow = false, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], } = opts;
3470
3625
  // This is the CLIENT-level schema — createHttpClient's default, and the
3471
3626
  // plugin's caller-facing contract (what executeHttp's return value promises
3472
3627
  // its own caller). It does NOT validate any individual call in a multi-step
@@ -3485,8 +3640,16 @@ function emitContractTs(opts) {
3485
3640
  // Single-endpoint plugins keep the same inferred-schema treatment, since
3486
3641
  // there both roles (client default and sole call) coincide.
3487
3642
  // Browser-flow-only plugins have no HTTP call to infer a shape from —
3488
- // z.unknown() there is the honest gap, not a narrowing shortcut.
3489
- const responseSchemaExpr = omitExecuteHttp ? `z.unknown()` : inferZodSchema(responseBody);
3643
+ // z.unknown() there is the honest gap, not a narrowing shortcut. A
3644
+ // submission flow is the exception: runHealingFlow's submitStep
3645
+ // verification already throws StepVerificationError on failure, so a
3646
+ // successful return IS a real signal — z.unknown() would be dishonest
3647
+ // in the other direction, hiding a field the flow can actually promise.
3648
+ const responseSchemaExpr = omitExecuteHttp && isSubmissionFlow
3649
+ ? `z.object({ verified: z.boolean() })`
3650
+ : omitExecuteHttp
3651
+ ? `z.unknown()`
3652
+ : inferZodSchema(responseBody);
3490
3653
  // Multi-step flows that include a multipart upload need the binary asset
3491
3654
  // on the payload. ApplicantContactSchema (via ApplicantResumeSchema) already
3492
3655
  // declares Resume/ResumeContentType/ResumeFilename, so submission flows
@@ -3632,6 +3795,7 @@ function emitContractTs(opts) {
3632
3795
  const sortedAdditionalKeys = discoveredAdditionalBodyKeys
3633
3796
  ? [...discoveredAdditionalBodyKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
3634
3797
  : [];
3798
+ let usesMultipartBoolean = false;
3635
3799
  for (const [name, kind] of sortedAdditionalKeys) {
3636
3800
  if (isReservedByApplicantContactSchema(name))
3637
3801
  continue;
@@ -3647,6 +3811,8 @@ function emitContractTs(opts) {
3647
3811
  : payloadNeedsMultipart
3648
3812
  ? "multipartBoolean()"
3649
3813
  : "z.boolean()";
3814
+ if (zod === "multipartBoolean()")
3815
+ usesMultipartBoolean = true;
3650
3816
  addExtendField(name, ` ${name}: ${zod},`);
3651
3817
  }
3652
3818
  // Mechanism B: nested caller structures become payload fields carrying their
@@ -3675,6 +3841,9 @@ function emitContractTs(opts) {
3675
3841
  const internalRequestReferenceExpr = inputBody
3676
3842
  ? inferZodSchema(inputBody, 0, "", { multipartCoerce: hasMultipartStep })
3677
3843
  : null;
3844
+ if (internalRequestReferenceExpr?.includes("multipartBoolean(")) {
3845
+ usesMultipartBoolean = true;
3846
+ }
3678
3847
  // All field sources above are merged into a SINGLE `.extend({...})` object
3679
3848
  // literal, keyed by field name — a name that recurs across sources (or
3680
3849
  // that collides with the base extend's own Email/ClickUrl/Answers) collapses
@@ -3684,12 +3853,12 @@ function emitContractTs(opts) {
3684
3853
  const payloadSchemaExpr = `${basePayloadSchemaExpr}${mergedExtension}`;
3685
3854
  // basePayloadSchemaExpr's own Answers field always wraps in
3686
3855
  // multipartJsonObject() for submission flows (inputBody set);
3687
- // multipartBoolean() and the structured-keys wrapping above are needed
3688
- // whenever payloadNeedsMultipart is true (an upload step OR a non-scalar
3689
- // discoveredStructuredKeys field).
3856
+ // multipartBoolean() is only imported when a boolean field was actually
3857
+ // wrapped in it above (an additional-body-key or an inputBody field under
3858
+ // multipartCoerce) — payloadNeedsMultipart alone doesn't imply that.
3690
3859
  // Named imports from the same module are combined into one import statement.
3691
3860
  const zodMultipartNamedImports = [
3692
- ...(payloadNeedsMultipart ? ["multipartBoolean"] : []),
3861
+ ...(usesMultipartBoolean ? ["multipartBoolean"] : []),
3693
3862
  ...(inputBody || (payloadNeedsMultipart && sortedStructuredEntries.length > 0)
3694
3863
  ? ["multipartJsonObject"]
3695
3864
  : []),
@@ -3703,7 +3872,7 @@ function emitContractTs(opts) {
3703
3872
  ? `import { ApplicantContactSchema } from "${ENGINE_PKG}/lib/applicant-payload";\n`
3704
3873
  : "";
3705
3874
  // Content-Type must be absent from multipart fetch calls so FormData can inject the boundary.
3706
- const caseInsensitiveHeadersImport = hasMultipartStep
3875
+ const caseInsensitiveHeadersImport = hasMultipartStep && !omitExecuteHttp
3707
3876
  ? `import { omitHeaderCaseInsensitive } from "${ENGINE_PKG}/lib/case-insensitive-headers";\n`
3708
3877
  : "";
3709
3878
  // Emit identifier-shaped keys unquoted so Biome's formatter doesn't rewrite
@@ -3768,7 +3937,7 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
3768
3937
  const fixtureComments = auxFiles.length > 0
3769
3938
  ? `\n// Fixtures downloaded by recon — commit to src/sites/${siteId}/fixtures/ and uncomment:\n` +
3770
3939
  auxFiles
3771
- .map((f) => `// const ${f.replace(".json", "")} = loadFixture(${JSON.stringify(siteId)}, ${JSON.stringify(f)}, z.unknown());`)
3940
+ .map((f) => `// const ${sanitizeFixtureIdentifier(f)} = loadFixture(${JSON.stringify(siteId)}, ${JSON.stringify(f)}, z.unknown());`)
3772
3941
  .join("\n") +
3773
3942
  "\n"
3774
3943
  : "";
@@ -3791,22 +3960,6 @@ const httpClient = createHttpClient({ schema: ${pascal}ResponseSchema, bottlenec
3791
3960
  export const ${pascal}InternalRequestReference = ${internalRequestReferenceExpr};
3792
3961
  `
3793
3962
  : "";
3794
- const queryChecklistLine = !omitExecuteHttp && gql
3795
- ? `\n * [ ] Trim UI-only fields from ${pascal.toUpperCase()}_QUERY (keep only fields you need)`
3796
- : "";
3797
- // Multi-step flows validate each call against its own per-call inferred
3798
- // schema (emitMultiStepExecuteHttp) — narrowing ResponseSchema only changes
3799
- // what executeHttp promises ITS OWN caller, never a per-call validator, so
3800
- // the checklist item must say that explicitly. Single-endpoint plugins have
3801
- // exactly one call, so the client schema and that call's validator are the
3802
- // same schema and the shorter wording stays accurate. Browser-flow-only
3803
- // plugins have no executeHttp at all, so ResponseSchema is only ever the
3804
- // browser flow's own return-value contract.
3805
- const narrowSchemaChecklistLine = omitExecuteHttp
3806
- ? `\n * [ ] Narrow ${pascal}ResponseSchema to match what the browser flow should promise ITS CALLER — this flow could not synthesize a trustworthy executeHttp (a required value from the captured sequence never resolved), so it ships browser-only`
3807
- : multiStepBody
3808
- ? `\n * [ ] Narrow ${pascal}ResponseSchema to match what executeHttp should promise ITS CALLER — this is the plugin's own return-value contract, not a per-call validator (each call in the flow is already checked against its own inferred schema)`
3809
- : `\n * [ ] Narrow ${pascal}ResponseSchema to match the real response shape`;
3810
3963
  const camel = siteId.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
3811
3964
  // Browser-flow-only plugins need neither Bottleneck (no rate-limited HTTP
3812
3965
  // client) nor BASE_HEADERS (no per-call headers to bake in) — both would
@@ -3819,10 +3972,13 @@ const BASE_HEADERS: Record<string, string> = {
3819
3972
  ${headersLiteral},
3820
3973
  };
3821
3974
  `;
3975
+ const rateLimitComment = hasRateLimitProbeData
3976
+ ? `// Safe ceiling: ${safeRps} rps — from recon rate-limit probe.`
3977
+ : `// Safe ceiling: ${safeRps} rps — DEFAULT (no rate-limit probe data; run recon:http).`;
3822
3978
  const limiterBlock = omitExecuteHttp
3823
3979
  ? ""
3824
3980
  : `
3825
- // Safe ceiling: ${safeRps} rps — from recon rate-limit probe.
3981
+ ${rateLimitComment}
3826
3982
  const limiter = new Bottleneck({ minTime: ${minTime} });
3827
3983
  `;
3828
3984
  const executeHttpMethodBlock = omitExecuteHttp
@@ -3840,23 +3996,15 @@ ${executeHttpBody}
3840
3996
  ? `/**
3841
3997
  * Plugin for ${siteId}. Browser-flow-only: the captured multi-step submission
3842
3998
  * sequence could not be synthesized into a trustworthy direct-HTTP hot path
3843
- * (see the checklist above), so this always runs via Stagehand.
3999
+ * (see recon-generate's review checklist, logged to stdout at generation
4000
+ * time), so this always runs via Stagehand.
3844
4001
  */`
3845
4002
  : `/**
3846
4003
  * Plugin for ${siteId}. Tries the direct-HTTP hot path first; falls back to
3847
4004
  * Stagehand automatically on schema drift or bot challenge.
3848
4005
  */`;
3849
- const baseHeadersChecklistLine = omitExecuteHttp
3850
- ? ""
3851
- : `\n * [ ] Verify BASE_HEADERS — remove any that aren't load-bearing`;
3852
- const outOfTreeChecklistLine = omitExecuteHttp
3853
- ? `\n * [ ] Out-of-tree: \`pnpm add zod\` — this file imports it directly, and a\n * strict node_modules layout (pnpm) won't resolve it as a transitive\n * dep of @enricai/barnacle alone`
3854
- : `\n * [ ] Out-of-tree: \`pnpm add bottleneck zod\` — this file imports both\n * directly, and a strict node_modules layout (pnpm) won't resolve\n * them as transitive deps of @enricai/barnacle alone`;
3855
4006
  return `/**
3856
4007
  * Generated by recon-generate.ts — review before shipping.
3857
- *
3858
- * Checklist:${queryChecklistLine}${narrowSchemaChecklistLine}
3859
- * [ ] Adjust ${pascal}PayloadSchema to your actual request parameters${baseHeadersChecklistLine}${outOfTreeChecklistLine}
3860
4008
  */
3861
4009
 
3862
4010
  ${bottleneckImport}import { z } from "zod/v4";
@@ -3880,11 +4028,11 @@ ${pluginDocComment}
3880
4028
  export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
3881
4029
  meta: {
3882
4030
  siteId: ${JSON.stringify(siteId)},
3883
- displayName: ${JSON.stringify(pascal.replace(/([A-Z])/g, " $1").trim())},
3884
4031
  bodySchema: ${pascal}PayloadSchema,
3885
4032
  responseSchema: ${pascal}ResponseSchema,
3886
4033
  defaultBaseUrl: ${JSON.stringify(baseUrl)},
3887
- // multipart is required whenever the flow itself uploads a file
4034
+ ${payloadNeedsMultipart || inputBody
4035
+ ? `// multipart is required whenever the flow itself uploads a file
3888
4036
  // (hasMultipartStep), OR this is a submission flow (inputBody set) since
3889
4037
  // basePayloadSchemaExpr always requires a real Resume Buffer via
3890
4038
  // ApplicantContactSchema regardless of whether the recorded browser flow
@@ -3892,7 +4040,8 @@ export const ${camel}Plugin: SitePlugin<${pascal}Payload, ${pascal}Response> = {
3892
4040
  // discoveredStructuredKeys field (payloadNeedsMultipart), since the
3893
4041
  // multipart wire format is what makes that field's JSON-stringified
3894
4042
  // encoding parseable.
3895
- apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || inputBody ? "\n multipart: true," : ""}
4043
+ `
4044
+ : ""}apiVersion: ${JSON.stringify(plugin_api_version_1.PLUGIN_API_VERSION)},${payloadNeedsMultipart || inputBody ? "\n multipart: true," : ""}
3896
4045
  },
3897
4046
  ${executeHttpMethodBlock}
3898
4047
  /** Browser fallback: Stagehand + Steel — invoked only when hot path fails. */
@@ -3901,7 +4050,7 @@ ${executeHttpMethodBlock}
3901
4050
  session: BrowserSession,
3902
4051
  context: SitePluginContext
3903
4052
  ): Promise<SitePluginResult<${pascal}Response>> {
3904
- const raw = await run${pascal}BrowserFlow(session.stagehand, context.baseUrl, payload);
4053
+ const raw = await run${pascal}BrowserFlow(session.stagehand, ${inputBody ? "payload.ClickUrl" : "context.baseUrl"}, payload);
3905
4054
  return { data: raw as ${pascal}Response };
3906
4055
  },
3907
4056
  };
@@ -3912,6 +4061,29 @@ ${executeHttpMethodBlock}
3912
4061
  export { ${camel}Plugin as plugin };
3913
4062
  `;
3914
4063
  }
4064
+ /**
4065
+ * Fails generation loudly when the payload schema `emitContractTs` just wrote
4066
+ * declares a required `*Url` field (e.g. `ClickUrl`) that neither the
4067
+ * contract's own `execute()` call site nor the emitted browser flow ever
4068
+ * reads as `payload.<Field>`. A required URL field nothing dereferences means
4069
+ * the flow enters on the wrong page (see
4070
+ * docs/recon-generate-browser-flow-entry-url-pii-and-unverified-submit.md
4071
+ * defect 1) — this generalizes the check past ClickUrl so any future
4072
+ * required URL field regresses loudly instead of silently.
4073
+ */
4074
+ function assertRequiredUrlFieldsReferenced(contractCode, browserFlowCode) {
4075
+ const urlFieldLinePattern = /^\s*(\w*Url\w*):\s*z\.\w+\(.*$/gm;
4076
+ const emittedCode = `${contractCode}\n${browserFlowCode}`;
4077
+ const unreferenced = [...contractCode.matchAll(urlFieldLinePattern)]
4078
+ .filter((match) => !match[0].includes(".optional("))
4079
+ .map((match) => match[1])
4080
+ .filter((name) => name !== undefined)
4081
+ .filter((name) => !emittedCode.includes(`payload.${name}`));
4082
+ if (unreferenced.length === 0)
4083
+ return;
4084
+ throw new Error(`recon-generate: required URL field(s) ${unreferenced.join(", ")} declared on the payload schema ` +
4085
+ `but never referenced by the emitted contract or browser flow — the flow would enter on the wrong page`);
4086
+ }
3915
4087
  /**
3916
4088
  * Escape a literal string segment so it is safe INSIDE a JS backtick template
3917
4089
  * literal — backslashes, backticks, and `${` interpolation starts must all be
@@ -3939,6 +4111,19 @@ function buildStepInstructionExpr(instruction, field) {
3939
4111
  return JSON.stringify(instruction);
3940
4112
  return `\`${escapeForTemplateLiteral(site.before)}\${payload.${field}}${escapeForTemplateLiteral(site.after)}\``;
3941
4113
  }
4114
+ /**
4115
+ * Build the emitted instruction expression for a step whose value is the
4116
+ * concatenation of two known fields ({@link resolveCompositePersonaFields}):
4117
+ * a backtick template literal with the whole quoted value replaced by
4118
+ * `${payload.<fieldA>} ${payload.<fieldB>}`, preserving the single space the
4119
+ * concatenation was built with.
4120
+ */
4121
+ function buildCompositeStepInstructionExpr(instruction, fieldA, fieldB) {
4122
+ const site = locateSpliceSite(instruction);
4123
+ if (site === null)
4124
+ return JSON.stringify(instruction);
4125
+ return `\`${escapeForTemplateLiteral(site.before)}\${payload.${fieldA}} \${payload.${fieldB}}${escapeForTemplateLiteral(site.after)}\``;
4126
+ }
3942
4127
  /**
3943
4128
  * Build the emitted instruction expression for a step whose splice site is the
3944
4129
  * reserved `${RECON_PASSWORD}` token: a backtick template literal with the
@@ -4010,7 +4195,7 @@ function jsonSchemaTypeOf(value) {
4010
4195
  * browser `flow` is the only execution path, and the field is omitted.
4011
4196
  */
4012
4197
  function emitConfigManifest(opts) {
4013
- const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, env = process.env, } = opts;
4198
+ const { siteId, displayName, baseUrl, flowSteps, vocabulary, inputBody, recoveredFields, httpModulePath, isSubmissionFlow = false, env = process.env, } = opts;
4014
4199
  const payloadFieldNames = new Set();
4015
4200
  const knownFieldValues = buildKnownFieldValues(flowSteps, vocabulary ?? vocabulary_1.EMPTY_VOCABULARY, env);
4016
4201
  const steps = flowSteps.map((step) => {
@@ -4042,6 +4227,23 @@ function emitConfigManifest(opts) {
4042
4227
  return rewritten;
4043
4228
  return { step: rewritten, optional, upload, submitStep };
4044
4229
  });
4230
+ // The last step of a submission flow is structurally the submit — force
4231
+ // submitStep:true even when the source recon-flow.json step never declared
4232
+ // it, mirroring emitBrowserFlowTs's forcing (see there) so a config-plugin
4233
+ // load of this manifest also gates on runHealingFlow's submitStep verification.
4234
+ const lastStepIndex = steps.length - 1;
4235
+ const lastStep = steps[lastStepIndex];
4236
+ if (isSubmissionFlow && lastStep !== undefined) {
4237
+ steps[lastStepIndex] =
4238
+ typeof lastStep === "string"
4239
+ ? { step: lastStep, optional: false, upload: false, submitStep: true }
4240
+ : {
4241
+ step: lastStep.step,
4242
+ optional: lastStep.optional,
4243
+ upload: lastStep.upload,
4244
+ submitStep: true,
4245
+ };
4246
+ }
4045
4247
  // The request surface, widest wins: a flow splice, a recovered form field, or
4046
4248
  // a key from the first POST body all name something a caller controls. Splices
4047
4249
  // and recovered fields are strings (the browser flow fills them as text); a
@@ -4062,7 +4264,7 @@ function emitConfigManifest(opts) {
4062
4264
  const manifest = {
4063
4265
  apiVersion: plugin_manifest_envelope_1.CONFIG_PLUGIN_API_VERSION,
4064
4266
  kind: plugin_manifest_envelope_1.CONFIG_PLUGIN_KIND,
4065
- metadata: { siteId, displayName },
4267
+ metadata: { siteId, ...(displayName !== undefined && { displayName }) },
4066
4268
  spec: {
4067
4269
  defaultBaseUrl: baseUrl,
4068
4270
  ...(httpModulePath ? { httpModule: httpModulePath } : {}),
@@ -4114,14 +4316,30 @@ function emitBrowserFlowTs(opts) {
4114
4316
  return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
4115
4317
  }
4116
4318
  const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary, knownFieldValues);
4319
+ const composite = field === null && !(isObj && step.payloadFieldNone)
4320
+ ? resolveCompositePersonaFields(instruction, knownFieldValues)
4321
+ : null;
4117
4322
  if (field !== null)
4118
4323
  payloadFieldNames.add(field);
4119
- const instructionExpr = buildStepInstructionExpr(instruction, field);
4324
+ if (composite !== null) {
4325
+ payloadFieldNames.add(composite.fieldA);
4326
+ payloadFieldNames.add(composite.fieldB);
4327
+ }
4328
+ const instructionExpr = composite !== null
4329
+ ? buildCompositeStepInstructionExpr(instruction, composite.fieldA, composite.fieldB)
4330
+ : buildStepInstructionExpr(instruction, field);
4120
4331
  const optional = isObj ? step.optional === true : false;
4121
4332
  const upload = isObj ? step.upload === true : false;
4122
4333
  const submitStep = isObj ? step.submitStep === true : false;
4123
4334
  return ` { instruction: ${instructionExpr}, optional: ${optional}, upload: ${upload}, submitStep: ${submitStep} },`;
4124
4335
  });
4336
+ // The last step of a submission flow is structurally the submit — force
4337
+ // submitStep:true even when the source recon-flow.json step never declared
4338
+ // it, so the engine's pre-submit probe/StepVerificationError actually gates it.
4339
+ const lastStepLiteral = stepLiterals.at(-1);
4340
+ if (isSubmissionFlow && lastStepLiteral !== undefined) {
4341
+ stepLiterals[stepLiterals.length - 1] = lastStepLiteral.replace(/submitStep: (true|false)(?= \},)/, "submitStep: true");
4342
+ }
4125
4343
  const flowStepsBlock = stepLiterals.length > 0
4126
4344
  ? stepLiterals.join("\n")
4127
4345
  : " // TODO: no flow steps were parsed. Re-run recon-browser with a --flow\n" +
@@ -4148,8 +4366,8 @@ function emitBrowserFlowTs(opts) {
4148
4366
  * Core invokes this automatically when executeHttp throws HttpSchemaError or
4149
4367
  * HttpBotChallengeError. Update the flow steps and extract schema as needed.
4150
4368
  *
4151
- * Steps whose instruction named a candidate PII label have their recon
4152
- * constant spliced to \`payload.<field>\` so the caller's real applicant reaches
4369
+ * Steps whose instruction named a labeled payload field have their recon
4370
+ * constant spliced to \`payload.<field>\` so the caller's real value reaches
4153
4371
  * the page; operational-default steps stay literal. The steps run through the
4154
4372
  * self-heal cascade via runHealingFlow — the same engine the recon CLI uses,
4155
4373
  * minus its disk-dump/replan layer.
@@ -4166,9 +4384,13 @@ import type { ${pascal}Payload, ${pascal}Response } from "@/sites/${siteId}/cont
4166
4384
 
4167
4385
  const logger = getLogger({ name: "${siteId}-browser-flow" });
4168
4386
 
4169
- const ${pascal}BrowserSchema = z.object({
4170
- // TODO: define the fields you need — align with ${pascal}Response
4171
- extraction: z.string(),
4387
+ const ${pascal}BrowserSchema = z.object({${isSubmissionFlow
4388
+ ? `
4389
+ // runHealingFlow throws StepVerificationError on a failed submitStep, so
4390
+ // reaching this point already proves the submission verified.
4391
+ verified: z.boolean(),`
4392
+ : `
4393
+ extraction: z.string(),`}
4172
4394
  });
4173
4395
 
4174
4396
  /**
@@ -4178,13 +4400,13 @@ const ${pascal}BrowserSchema = z.object({
4178
4400
  */
4179
4401
  export async function run${pascal}BrowserFlow(
4180
4402
  stagehand: Stagehand,
4181
- baseUrl: string,
4403
+ ${isSubmissionFlow ? "entryUrl" : "baseUrl"}: string,
4182
4404
  payload: ${pascal}Payload
4183
4405
  ): Promise<${pascal}Response> {
4184
4406
  const page = await stagehand.context.awaitActivePage();
4185
4407
 
4186
- await page.goto(baseUrl, { waitUntil: "networkidle" });
4187
- // networkidle can resolve before a Cloudflare-fronted SPA hydrates; wait for
4408
+ await page.goto(${isSubmissionFlow ? "entryUrl" : "baseUrl"}, { waitUntil: "networkidle" });
4409
+ // networkidle can resolve before a bot-managed/CDN-fronted SPA hydrates; wait for
4188
4410
  // the real DOM so the first steps don't probe an empty shell page and skip.
4189
4411
  await waitForSpaReady(page, logger);
4190
4412
  ${usesThrowawayPassword ? "\n // Minted once per run — the flow needs a credential to authenticate, but\n // there is no caller-supplied Password field on the payload to splice.\n const throwawayPassword = generateThrowawayPassword();\n" : ""}
@@ -4210,16 +4432,37 @@ ${flowStepsBlock}
4210
4432
  // both Zod v3 and v4 schemas natively (StagehandZodSchema union since
4211
4433
  // 2.4.3 / PR #944), and the caller-side safeParse defends against SDK
4212
4434
  // contract drift. Widen ${pascal}BrowserSchema as needed to match the
4213
- // fields the recon flow actually surfaces.
4435
+ // fields the recon flow actually surfaces.${isSubmissionFlow
4436
+ ? `
4437
+ // runHealingFlow above already verified the submit (submitStep:true), so the
4438
+ // application is already committed at the ATS. A schema-validation or
4439
+ // watchdog-timeout throw from this trailing confirmation extract must not
4440
+ // propagate and trigger a duplicate re-dispatch — catch it, log it, and
4441
+ // degrade to a not-confirmed result instead.
4442
+ try {
4443
+ const result = await guardedExtract(
4444
+ stagehand,
4445
+ "is a submission confirmation shown, and what is its reference number?",
4446
+ ${pascal}BrowserSchema
4447
+ );
4448
+ return { ...result, verified: true } as unknown as ${pascal}Response;
4449
+ } catch (error) {
4450
+ logger.error(\`GuardedExtractError: post-submit confirmation extract failed on an already-verified submit, degrading to not-confirmed: \${error}\`);
4451
+ return { verified: false } as unknown as ${pascal}Response;
4452
+ }
4453
+ }
4454
+ `
4455
+ : `
4214
4456
  const result = await guardedExtract(
4215
4457
  stagehand,
4216
- ${isSubmissionFlow ? `\`drove the ${siteId} submission flow for payload \${JSON.stringify(payload)}\`` : `\`extract results matching query: \${payload.query}\``},
4458
+ \`extract results matching query: \${payload.query}\`,
4217
4459
  ${pascal}BrowserSchema
4218
4460
  );
4219
4461
 
4220
4462
  return result as unknown as ${pascal}Response;
4221
4463
  }
4222
- `;
4464
+ `}`;
4465
+ assertNoLeakedPersonaConstant(code, knownFieldValues);
4223
4466
  return { code, payloadFieldNames };
4224
4467
  }
4225
4468
  /** Generates the site's index.ts barrel — exported so the out-of-tree e2e
@@ -4335,6 +4578,9 @@ async function main() {
4335
4578
  "rate-limit.json",
4336
4579
  "introspection-schema.json",
4337
4580
  ]);
4581
+ if (replays.length === 0) {
4582
+ logger.warn(`recon-generate: run dir ${runRoot} has an empty replays/ directory — recon:http (rate-limit probe) and replay validation were skipped, so this contract's timing and shape are unvalidated`);
4583
+ }
4338
4584
  const rateLimits = (() => {
4339
4585
  try {
4340
4586
  return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(replaysDir, "rate-limit.json"), "utf8"));
@@ -4410,8 +4656,8 @@ async function main() {
4410
4656
  const baseUrl = deriveBaseUrl(captures);
4411
4657
  const baseHeaders = deriveRequestHeaders(captures, replays, baseUrl, submitPatterns);
4412
4658
  const minTime = deriveMinTime(rateLimits);
4659
+ const hasRateLimitProbeData = rateLimits.some((f) => f.safeRps !== null);
4413
4660
  const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
4414
- const responseBody = firstSuccessfulReplayBody(replays);
4415
4661
  const gql = isGraphQL(captures);
4416
4662
  // Hoisted so both the primary-operation gate below and rawActionCaptures
4417
4663
  // (further down) read the same computed sequence instead of calling the
@@ -4423,6 +4669,11 @@ async function main() {
4423
4669
  : null;
4424
4670
  const gqlQuery = primaryGraphQLOperation?.capture.query ?? firstGraphQLQuery(captures);
4425
4671
  const endpointPath = primaryGraphQLOperation?.endpointPath ?? firstEndpointPath(captures);
4672
+ // Derived from the primary operation's own Phase-1 capture, never from
4673
+ // replay array order -- a replay's body reflects whichever endpoint fired
4674
+ // first, not necessarily the primary operation, and only exists once
4675
+ // recon:http has run.
4676
+ const responseBody = (primaryGraphQLOperation?.capture ?? firstEndpointCapture(captures))?.responseBody ?? null;
4426
4677
  // Detect a multi-step submission flow (transactional sites like apply forms,
4427
4678
  // checkout, etc.). When the action sequence has 2+ POSTs, switch the
4428
4679
  // contract template to emit a state-threaded executeHttp.
@@ -4656,12 +4907,12 @@ async function main() {
4656
4907
  (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
4657
4908
  (0, node_fs_1.writeFileSync)(manifestPath, emitConfigManifest({
4658
4909
  siteId,
4659
- displayName: pascal,
4660
4910
  baseUrl,
4661
4911
  flowSteps,
4662
4912
  vocabulary,
4663
4913
  inputBody,
4664
4914
  recoveredFields: [...discoveredFormFields, ...discoveredOptionFields],
4915
+ isSubmissionFlow,
4665
4916
  // A submission flow is the case where the `.ts` emit carries an
4666
4917
  // executeHttp hot path; point the manifest at where the operator drops
4667
4918
  // the compiled module rather than silently dropping the direct path.
@@ -4687,7 +4938,7 @@ async function main() {
4687
4938
  vocabulary,
4688
4939
  frameSelector,
4689
4940
  });
4690
- (0, node_fs_1.writeFileSync)(`${outDir}/contract.ts`, emitContractTs({
4941
+ const contractOpts = {
4691
4942
  siteId,
4692
4943
  pascal,
4693
4944
  baseUrl,
@@ -4696,6 +4947,7 @@ async function main() {
4696
4947
  baseHeaders: isSubmissionFlow ? staticBaseHeaders : baseHeaders,
4697
4948
  minTime,
4698
4949
  safeRps,
4950
+ hasRateLimitProbeData,
4699
4951
  responseBody: effectiveResponseBody,
4700
4952
  gql,
4701
4953
  gqlQuery,
@@ -4705,6 +4957,7 @@ async function main() {
4705
4957
  auxFiles,
4706
4958
  multiStepBody,
4707
4959
  omitExecuteHttp: browserFlowOnly,
4960
+ isSubmissionFlow,
4708
4961
  inputBody,
4709
4962
  hasMultipartStep,
4710
4963
  discoveredFormFields,
@@ -4715,8 +4968,17 @@ async function main() {
4715
4968
  discoveredStructuredKeys,
4716
4969
  payloadFieldNames: browserFlow.payloadFieldNames,
4717
4970
  headerBindings,
4718
- }));
4971
+ };
4972
+ const contractCode = emitContractTs(contractOpts);
4973
+ // Fails loudly rather than shipping a flow that requires a URL field it
4974
+ // never reads (see assertRequiredUrlFieldsReferenced doc comment).
4975
+ assertRequiredUrlFieldsReferenced(contractCode, browserFlow.code);
4976
+ (0, node_fs_1.writeFileSync)(`${outDir}/contract.ts`, contractCode);
4719
4977
  logger.info(`wrote ${outDir}/contract.ts`);
4978
+ // Same opts fed to emitContractTs above, so this can never drift from what
4979
+ // the header used to embed.
4980
+ const checklist = buildContractChecklist(contractOpts);
4981
+ logger.info(`review checklist for ${outDir}/contract.ts:\n${checklist.map((item) => ` [ ] ${item}`).join("\n")}`);
4720
4982
  (0, node_fs_1.writeFileSync)(`${outDir}/flows/browser-flow.ts`, browserFlow.code);
4721
4983
  logger.info(`wrote ${outDir}/flows/browser-flow.ts`);
4722
4984
  (0, node_fs_1.writeFileSync)(`${outDir}/index.ts`, emitIndexTs({ siteId, pascal }));