@thinkingai/ae-cli 6.1.17 → 6.1.19

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.
Files changed (126) hide show
  1. package/README.md +2 -0
  2. package/README.zh.md +6 -0
  3. package/dist/{auth-77BUFLGC.js → auth-QPEVVBKT.js} +20 -15
  4. package/dist/auth-UMJAA72O.js +15 -0
  5. package/dist/{capability-72DTW5M2.js → capability-ASI62I4A.js} +53 -15
  6. package/dist/{chunk-UW5UN47B.js → chunk-533JK3YX.js} +2 -0
  7. package/dist/{chunk-UOUS37JQ.js → chunk-6QK7SYYY.js} +5 -4
  8. package/dist/{chunk-TUKQZTMI.js → chunk-7K24F7T2.js} +2 -0
  9. package/dist/{chunk-OO6XR6LK.js → chunk-AMBFK2K3.js} +2 -0
  10. package/dist/{sync-DAVKYVMW.js → chunk-B5HMQAOM.js} +31 -540
  11. package/dist/{chunk-VR3LCBHW.js → chunk-DKEXH6Q5.js} +6 -5
  12. package/dist/{chunk-VTWMIC5L.js → chunk-E2JKXMVO.js} +2 -0
  13. package/dist/{chunk-LYVNONC4.js → chunk-FR2EDAQM.js} +36 -0
  14. package/dist/{chunk-ILIU36SU.js → chunk-GJXRF7Y6.js} +6 -5
  15. package/dist/{chunk-DWO43OIB.js → chunk-JQ3ENZZH.js} +2 -0
  16. package/dist/chunk-JYMGHGCE.js +191 -0
  17. package/dist/{chunk-GT46FPXN.js → chunk-KEWVSO64.js} +18 -8
  18. package/dist/chunk-KZ6ZHF5J.js +294 -0
  19. package/dist/chunk-O6K2ODUE.js +318 -0
  20. package/dist/chunk-QL6R6TSK.js +25215 -0
  21. package/dist/{chunk-4SGZG4XY.js → chunk-RWXIXOZG.js} +28 -13
  22. package/dist/{chunk-YA6SMTXG.js → chunk-S5PONFBM.js} +5 -4
  23. package/dist/{chunk-4NN5IWVN.js → chunk-T6OSFQZD.js} +2 -0
  24. package/dist/{chunk-PTE56QPL.js → chunk-TKHSULJT.js} +6 -0
  25. package/dist/{chunk-6EIJSNBD.js → chunk-Y74WTIKJ.js} +2 -0
  26. package/dist/{client-TKG4WBHN.js → client-N7NGZUNI.js} +6 -4
  27. package/dist/{community-report-client-FI4LNVYS.js → community-report-client-6OL6GBQW.js} +3 -3
  28. package/dist/{config-RE6CMGPK.js → config-XNMACCKX.js} +10 -8
  29. package/dist/{data-integration-XQYB4X4F.js → data-integration-4NZGZ45E.js} +2502 -272
  30. package/dist/index.js +137 -1245
  31. package/dist/{local-data-upload-client-BWHSUQQK.js → local-data-upload-client-EFHDLXFG.js} +3 -3
  32. package/dist/{memory-YK33G4T7.js → memory-HBJQ2GC5.js} +8 -7
  33. package/dist/{metadata-UILXHBWF.js → metadata-62EWBEIG.js} +12 -11
  34. package/dist/{model-NR3JHFSJ.js → model-BHLJ2QMT.js} +10 -9
  35. package/dist/personal-semantic-preference-JBVMAI7L.js +240 -0
  36. package/dist/project-semantic-BQ4MPUS5.js +1115 -0
  37. package/dist/sync-MUWPRM5I.js +522 -0
  38. package/dist/{te-agent-4BKBODMF.js → te-agent-WQZTMEK4.js} +12 -10
  39. package/dist/{te-analysis-O6DCO6BS.js → te-analysis-MNCG65LU.js} +534 -48
  40. package/dist/{te-community-HLC43QKH.js → te-community-BNE6KREL.js} +8 -7
  41. package/dist/{te-dataops-EJP56W3K.js → te-dataops-TXLKM2W6.js} +469 -68
  42. package/dist/{te-engage-FGBGQ4IY.js → te-engage-TPKLRVTL.js} +84 -10
  43. package/dist/{te-experiment-SO5MPDMJ.js → te-experiment-6BYKTCY7.js} +303 -12
  44. package/dist/{te-kb-SQCLHG6X.js → te-kb-BUD6N3NI.js} +312 -25
  45. package/dist/{te-system-YARIK4S5.js → te-system-44LZJDTQ.js} +573 -37
  46. package/dist/{te-team-EFKWYKMK.js → te-team-XMP47YXE.js} +10 -7
  47. package/dist/{update-OGPSZM5A.js → update-UODFOZ4P.js} +9 -7
  48. package/package.json +18 -9
  49. package/skills/ae-agent/SKILL.md +3 -4
  50. package/skills/ae-agent/references/edit-skill.md +3 -0
  51. package/skills/ae-agent/references/get-skill-content.md +1 -1
  52. package/skills/ae-agent/references/rescan-skills.md +15 -13
  53. package/skills/ae-agent/references/upload-skill.md +7 -4
  54. package/skills/ae-analysis/SKILL.md +45 -4
  55. package/skills/ae-analysis/metadata_resolution.md +38 -4
  56. package/skills/ae-analysis/references/analysis_data_retrieval.md +29 -0
  57. package/skills/ae-analysis/references/analysis_gateway_assets.md +1 -1
  58. package/skills/ae-analysis/references/asset_authentication_export.md +22 -0
  59. package/skills/ae-analysis/references/asset_authentication_list.md +18 -14
  60. package/skills/ae-analysis/references/asset_authentication_update.md +29 -14
  61. package/skills/ae-analysis/references/audience_models.md +18 -0
  62. package/skills/ae-analysis/references/command_index.md +19 -11
  63. package/skills/ae-analysis/references/dashboard_get.md +18 -1
  64. package/skills/ae-analysis/references/dashboard_update.md +3 -0
  65. package/skills/ae-analysis/references/personal_semantic_preference_add.md +23 -0
  66. package/skills/ae-analysis/references/personal_semantic_preference_delete.md +17 -0
  67. package/skills/ae-analysis/references/personal_semantic_preference_get.md +19 -0
  68. package/skills/ae-analysis/references/personal_semantic_preference_list.md +21 -0
  69. package/skills/ae-analysis/references/personal_semantic_preference_update.md +19 -0
  70. package/skills/ae-analysis/references/project_timezone_update.md +13 -4
  71. package/skills/ae-analysis/references/property_create.md +2 -0
  72. package/skills/ae-analysis/references/super_metadata_batch_create.md +2 -0
  73. package/skills/ae-analysis/references/user_cluster_models.md +2 -0
  74. package/skills/ae-analysis/references/user_cluster_update.md +8 -4
  75. package/skills/ae-analysis/references/user_tag_create.md +7 -1
  76. package/skills/ae-analysis/references/user_tag_models.md +8 -2
  77. package/skills/ae-data-integration/SKILL.md +24 -4
  78. package/skills/ae-data-integration/references/custom-layer.md +93 -0
  79. package/skills/ae-data-integration/references/error-handling.md +145 -0
  80. package/skills/ae-data-integration/references/handoff.md +77 -18
  81. package/skills/ae-data-integration/references/local-analysis.md +1 -1
  82. package/skills/ae-data-integration/references/reuse.md +9 -5
  83. package/skills/ae-data-integration/references/sink-upload.md +1 -1
  84. package/skills/ae-data-integration/references/source-inspect.md +35 -13
  85. package/skills/ae-data-integration/references/tracking-plan.md +7 -5
  86. package/skills/ae-data-integration/references/transform.md +12 -10
  87. package/skills/ae-data-integration/references/ue-mapping.md +33 -11
  88. package/skills/ae-data-integration/references/ue-routing.md +23 -0
  89. package/skills/ae-dataops/SKILL.md +11 -1
  90. package/skills/ae-dataops/references/dataops-backfill.md +135 -0
  91. package/skills/ae-engage/SKILL.md +5 -0
  92. package/skills/ae-engage/references/build-task-save-guide.md +14 -1
  93. package/skills/ae-engage/references/save-flow.md +37 -1
  94. package/skills/ae-engage/references/save-task.md +88 -0
  95. package/skills/ae-experiment/SKILL.md +14 -4
  96. package/skills/ae-experiment/references/manage_feature_whitelist.md +66 -0
  97. package/skills/ae-experiment/references/manage_guardrail_metrics.md +26 -0
  98. package/skills/ae-experiment/references/save_experiment.md +1 -1
  99. package/skills/ae-experiment/references/save_metric.md +20 -8
  100. package/skills/ae-kb/SKILL.md +121 -10
  101. package/skills/ae-project-semantic/SKILL.md +193 -0
  102. package/skills/ae-project-semantic/references/query-routing-v5.md +165 -0
  103. package/skills/ae-project-semantic/references/recommendation-quality.md +68 -0
  104. package/skills/ae-system/SKILL.md +28 -30
  105. package/skills/ae-system/references/channel-management.md +303 -0
  106. package/dist/auth-2WTQOP77.js +0 -14
  107. package/dist/capability-PJHNI4GJ.js +0 -315
  108. package/dist/chunk-4KVPKXFX.js +0 -98
  109. package/dist/chunk-C4MGVGJW.js +0 -13
  110. package/dist/chunk-P3FGXJTU.js +0 -349
  111. package/dist/chunk-QGM4M3NI.js +0 -37
  112. package/dist/chunk-RGKJGKT7.js +0 -71
  113. package/dist/chunk-SAU3QFIQ.js +0 -556
  114. package/dist/chunk-VPKZ7I72.js +0 -509
  115. package/dist/chunk-ZZUOD757.js +0 -598
  116. package/dist/memory-CHRU2F7W.js +0 -893
  117. package/dist/metadata-XXR34N5P.js +0 -339
  118. package/dist/model-K3KLWIW6.js +0 -139
  119. package/dist/sync-FCKOVWWS.js +0 -10261
  120. package/dist/te-agent-HLW4VTQK.js +0 -3893
  121. package/dist/te-analysis-ZMNGOVNW.js +0 -8958
  122. package/dist/te-community-6HPBWJUZ.js +0 -1859
  123. package/dist/te-dataops-HDRUXY4K.js +0 -2208
  124. package/dist/te-engage-RAK5PESW.js +0 -4898
  125. package/dist/te-experiment-VZF7BT6G.js +0 -770
  126. package/dist/te-system-Z77IKZFN.js +0 -2213
@@ -1,13 +1,27 @@
1
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
+ import {
3
+ require_excel,
4
+ require_styles_xform,
5
+ require_worksheet_reader,
6
+ validateAndFix,
7
+ validateDraft
8
+ } from "./chunk-QL6R6TSK.js";
9
+ import "./chunk-B5HMQAOM.js";
10
+ import {
11
+ getConfigDir
12
+ } from "./chunk-KZ6ZHF5J.js";
1
13
  import {
2
14
  CliValidationError,
3
15
  LocalDataUploadError
4
- } from "./chunk-UW5UN47B.js";
5
- import "./chunk-QGM4M3NI.js";
16
+ } from "./chunk-533JK3YX.js";
17
+ import {
18
+ __toESM
19
+ } from "./chunk-FR2EDAQM.js";
6
20
 
7
- // src/commands/data-integration/local-data/inspect.ts
21
+ // src/commands/data-integration/inspect.ts
8
22
  import { basename as basename3 } from "path";
9
23
 
10
- // src/commands/data-integration/local-data/estimate.ts
24
+ // src/commands/data-integration/estimate.ts
11
25
  var XLS_SIZE_WARN_BYTES = 100 * 1024 * 1024;
12
26
  var LARGE_FILE_WARN_BYTES = 1024 * 1024 * 1024;
13
27
  var XLS_HARD_LIMIT_BYTES = 1024 * 1024 * 1024;
@@ -95,12 +109,15 @@ function assessFileSize(fileName, format, sizeBytes, encoding) {
95
109
  return baseAssessment;
96
110
  }
97
111
 
98
- // src/commands/data-integration/local-data/input.ts
112
+ // src/commands/data-integration/input.ts
113
+ var import_exceljs = __toESM(require_excel(), 1);
114
+ var import_worksheet_reader = __toESM(require_worksheet_reader(), 1);
115
+ var import_styles_xform = __toESM(require_styles_xform(), 1);
99
116
  import { createHash } from "crypto";
100
117
  import { createReadStream as createReadStream2, statSync } from "fs";
101
- import { createRequire as createRequire2 } from "module";
102
118
  import { extname, basename } from "path";
103
119
  import { createInterface } from "readline";
120
+ import { StringDecoder } from "string_decoder";
104
121
  import { pipeline } from "stream/promises";
105
122
  import XLSXMod from "xlsx";
106
123
  import { parse as parseCsv } from "csv-parse";
@@ -112,7 +129,7 @@ import StreamValues from "stream-json/streamers/StreamValues.js";
112
129
  import * as unzipper from "unzipper";
113
130
  import { SaxesParser } from "saxes";
114
131
 
115
- // src/commands/data-integration/local-data/encoding.ts
132
+ // src/commands/data-integration/encoding.ts
116
133
  import { createReadStream } from "fs";
117
134
  import { createRequire } from "module";
118
135
  import { openSync, readSync, closeSync } from "fs";
@@ -156,7 +173,7 @@ function readSample(filePath, maxBytes) {
156
173
  }
157
174
  }
158
175
 
159
- // src/commands/data-integration/local-data/time.ts
176
+ // src/commands/data-integration/time.ts
160
177
  var TIME_FORMATS = [
161
178
  // Standard AE format
162
179
  "yyyy-MM-dd HH:mm:ss.SSS",
@@ -400,7 +417,7 @@ function tokenizeFormat(format) {
400
417
  return tokens;
401
418
  }
402
419
 
403
- // src/commands/data-integration/local-data/flatten.ts
420
+ // src/commands/data-integration/flatten.ts
404
421
  var NDJSON_MAX_DEPTH = 1;
405
422
  var NESTED_NODE_SAMPLE_LIMIT = 5;
406
423
  var NESTED_SAMPLE_TRUNCATE = 40;
@@ -433,7 +450,7 @@ function flattenJSON(obj, prefix = "", depth = 0, maxDepth = NDJSON_MAX_DEPTH) {
433
450
  }
434
451
  return result;
435
452
  }
436
- function buildRowWithFlatten(obj, flattenRules) {
453
+ function buildRowWithFlatten(obj, flattenRules, misses) {
437
454
  const row = {};
438
455
  const coveredRoots = new Set(Object.values(flattenRules).map((path) => path.split(".")[0]));
439
456
  const base = flattenJSON(obj);
@@ -442,43 +459,77 @@ function buildRowWithFlatten(obj, flattenRules) {
442
459
  }
443
460
  for (const [outColumn, sourcePath] of Object.entries(flattenRules)) {
444
461
  const value = getNestedValue(obj, sourcePath);
445
- row[outColumn] = value == null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
462
+ if (value == null) {
463
+ recordFlattenMiss(misses, outColumn);
464
+ row[outColumn] = "";
465
+ } else {
466
+ row[outColumn] = typeof value === "object" ? JSON.stringify(value) : String(value);
467
+ }
446
468
  }
447
469
  return row;
448
470
  }
449
- function flattenLocalDataRow(value, flattenRules) {
471
+ function flattenLocalDataRow(value, flattenRules, misses) {
450
472
  if (value !== null && typeof value === "object" && !Array.isArray(value)) {
451
473
  if (flattenRules && Object.keys(flattenRules).length > 0) {
452
- return buildRowWithFlatten(value, flattenRules);
474
+ return buildRowWithFlatten(value, flattenRules, misses);
453
475
  }
454
476
  return value;
455
477
  }
456
478
  return { value };
457
479
  }
458
- function flattenDelimitedRow(row, flattenRules) {
480
+ function flattenDelimitedRow(row, flattenRules, misses) {
459
481
  const result = { ...row };
460
482
  for (const [outColumn, path] of Object.entries(flattenRules)) {
461
483
  const dot = path.indexOf(".");
462
- if (dot <= 0) continue;
484
+ if (dot <= 0) {
485
+ recordFlattenMiss(misses, outColumn);
486
+ continue;
487
+ }
463
488
  const column = path.slice(0, dot);
464
489
  const cell = row[column];
465
- if (typeof cell !== "string") continue;
490
+ if (typeof cell !== "string") {
491
+ recordFlattenMiss(misses, outColumn);
492
+ continue;
493
+ }
466
494
  let parsed;
467
495
  try {
468
496
  parsed = JSON.parse(cell);
469
497
  } catch {
498
+ recordFlattenMiss(misses, outColumn);
470
499
  continue;
471
500
  }
472
501
  const value = getNestedValue(parsed, path.slice(dot + 1));
473
- if (value === null || value === void 0) continue;
502
+ if (value === null || value === void 0) {
503
+ recordFlattenMiss(misses, outColumn);
504
+ continue;
505
+ }
474
506
  result[outColumn] = typeof value === "object" ? JSON.stringify(value) : String(value);
475
507
  }
476
508
  return result;
477
509
  }
510
+ function recordFlattenMiss(misses, outColumn) {
511
+ if (!misses) return;
512
+ misses[outColumn] = (misses[outColumn] ?? 0) + 1;
513
+ }
478
514
  function buildNestedTree(rows) {
479
515
  const objects = rows.filter((row) => row !== null && typeof row === "object" && !Array.isArray(row));
480
516
  return buildObjectChildren(objects, "");
481
517
  }
518
+ function buildColumnNestedTree(cells) {
519
+ const objects = cells.filter((cell) => cell !== null && typeof cell === "object" && !Array.isArray(cell));
520
+ const arrays = cells.filter(Array.isArray);
521
+ if (objects.length >= arrays.length) {
522
+ return objects.length > 0 ? buildObjectChildren(objects, "") : [];
523
+ }
524
+ const elements = arrays.flat();
525
+ const elementObjects = elements.filter(
526
+ (value) => value !== null && typeof value === "object" && !Array.isArray(value)
527
+ );
528
+ const elementKind = elementObjects.length > 0 ? "object" : "primitive";
529
+ const node = { path: "", name: "", kind: "array", elementKind, nonEmpty: elements.length > 0 };
530
+ if (elementKind === "object") node.children = buildObjectChildren(elementObjects, "");
531
+ return [node];
532
+ }
482
533
  function buildObjectChildren(objects, parentPath) {
483
534
  const keys = [];
484
535
  const seen = /* @__PURE__ */ new Set();
@@ -504,8 +555,18 @@ function buildNode(path, name, values, nonEmpty) {
504
555
  if (structural > 0 && structural >= values.length - structural) {
505
556
  if (arrays.length > objects.length) {
506
557
  const elementValues = arrays.flat();
507
- const elementKind = elementValues.some((value) => value !== null && typeof value === "object") ? "object" : "primitive";
508
- return { path, name, kind: "array", elementKind, nonEmpty };
558
+ const elementObjects = elementValues.filter(
559
+ (value) => value !== null && typeof value === "object" && !Array.isArray(value)
560
+ );
561
+ const elementKind = elementObjects.length > 0 ? "object" : "primitive";
562
+ return {
563
+ path,
564
+ name,
565
+ kind: "array",
566
+ elementKind,
567
+ nonEmpty,
568
+ ...elementKind === "object" ? { children: buildObjectChildren(elementObjects, path) } : {}
569
+ };
509
570
  }
510
571
  return { path, name, kind: "object", children: buildObjectChildren(objects, path), nonEmpty };
511
572
  }
@@ -573,10 +634,8 @@ function isStrongDateTime(value) {
573
634
  return /^\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.test(value.trim());
574
635
  }
575
636
 
576
- // src/commands/data-integration/local-data/input.ts
637
+ // src/commands/data-integration/input.ts
577
638
  var XLSX = XLSXMod.default ?? XLSXMod;
578
- var require3 = createRequire2(import.meta.url);
579
- var ExcelWorksheetReader = require3("exceljs/lib/stream/xlsx/worksheet-reader");
580
639
  function resolveLocalDataInputMeta(filePath) {
581
640
  let format = resolveFormat(filePath);
582
641
  let delimiter;
@@ -613,10 +672,12 @@ async function inspectLocalDataInput(filePath) {
613
672
  const meta = resolveLocalDataInputMeta(filePath);
614
673
  emitSizeWarning(meta.filePath, meta.format, meta.sizeBytes, meta.encoding);
615
674
  try {
675
+ const discovered = await discoverDataSets(filePath, meta.format);
616
676
  return {
617
677
  ...meta,
618
678
  sha256: await sha256File(filePath),
619
- dataSets: await discoverDataSets(filePath, meta.format)
679
+ dataSets: discovered.visible,
680
+ ...discovered.hidden.length > 0 ? { excludedDataSets: discovered.hidden } : {}
620
681
  };
621
682
  } catch (error) {
622
683
  if (error instanceof CliValidationError) throw error;
@@ -639,15 +700,28 @@ function emitSizeWarning(filePath, format, sizeBytes, encoding) {
639
700
  }
640
701
  function selectDataSet(input, requested) {
641
702
  if (requested) {
642
- const selected = input.dataSets.find((candidate) => candidate.id === requested || candidate.selector === requested || candidate.label === requested);
643
- if (!selected) {
644
- throw new CliValidationError("The requested Sheet or JSON Path was not found.", {
645
- code: "LOCAL_DATA_SET_NOT_FOUND",
646
- hint: `Choose one of: ${input.dataSets.map((item) => item.id).join(", ")}`,
647
- location: { field: "data-set" }
648
- });
703
+ const matches = (candidate) => candidate.id === requested || candidate.selector === requested || candidate.label === requested;
704
+ const selected = input.dataSets.find(matches);
705
+ if (selected) return selected;
706
+ const excluded = input.excludedDataSets?.find(matches);
707
+ if (excluded) {
708
+ process.stderr.write(`Warning: "${excluded.label}" is hidden in the source workbook and was selected explicitly.
709
+ `);
710
+ return excluded;
649
711
  }
650
- return selected;
712
+ throw new CliValidationError("The requested Sheet or JSON Path was not found.", {
713
+ code: "LOCAL_DATA_SET_NOT_FOUND",
714
+ hint: `Choose one of: ${input.dataSets.map((item) => item.id).join(", ")}`,
715
+ location: { field: "data-set" }
716
+ });
717
+ }
718
+ const hidden = input.excludedDataSets ?? [];
719
+ if (input.dataSets.length === 0 && hidden.length > 0) {
720
+ throw new CliValidationError("Every data set in this file is hidden in the source.", {
721
+ code: "LOCAL_DATA_ALL_DATA_SETS_HIDDEN",
722
+ hint: `Confirm with the user, then pass --data-set with one of: ${hidden.map((item) => item.id).join(", ")}`,
723
+ location: { field: "data-set" }
724
+ });
651
725
  }
652
726
  if (input.dataSets.length !== 1) {
653
727
  throw new CliValidationError("This file contains multiple data sets.", {
@@ -658,6 +732,24 @@ function selectDataSet(input, requested) {
658
732
  }
659
733
  return input.dataSets[0];
660
734
  }
735
+ var LocalDataRowCallbackError = class extends Error {
736
+ constructor(cause) {
737
+ super(cause instanceof Error ? cause.message : String(cause));
738
+ this.cause = cause;
739
+ this.name = "LocalDataRowCallbackError";
740
+ }
741
+ cause;
742
+ };
743
+ function wrapRowCallback(onRow) {
744
+ return async (row, rowNumber) => {
745
+ try {
746
+ await onRow(row, rowNumber);
747
+ } catch (error) {
748
+ if (error instanceof CliValidationError) throw error;
749
+ throw new LocalDataRowCallbackError(error);
750
+ }
751
+ };
752
+ }
661
753
  async function streamLocalDataRows(input, dataSet, onRow, options = {}) {
662
754
  const opts = {
663
755
  delimiter: options.delimiter ?? input.delimiter ?? (input.format === "tsv" ? " " : ","),
@@ -665,24 +757,36 @@ async function streamLocalDataRows(input, dataSet, onRow, options = {}) {
665
757
  headerNames: options.headerNames,
666
758
  noHeader: options.noHeader,
667
759
  flattenRules: options.flattenRules,
668
- mergeSheets: options.mergeSheets
760
+ flattenMisses: options.flattenMisses,
761
+ mergeSheets: options.mergeSheets,
762
+ excelDateColumns: options.excelDateColumns,
763
+ cellIssues: options.cellIssues,
764
+ warnRagged: options.warnRagged,
765
+ skipRows: options.skipRows,
766
+ xlsxStructure: options.xlsxStructure,
767
+ fillMergedCells: options.fillMergedCells,
768
+ excludeHiddenRows: options.excludeHiddenRows
669
769
  };
770
+ const wrappedRow = wrapRowCallback(onRow);
670
771
  try {
671
772
  switch (input.format) {
672
773
  case "csv":
673
774
  case "tsv":
674
- return await streamDelimited(input.filePath, onRow, opts);
775
+ return await streamDelimited(input.filePath, wrappedRow, opts);
675
776
  case "jsonl":
676
- return await streamJsonLines(input.filePath, onRow, opts);
777
+ return await streamJsonLines(input.filePath, wrappedRow, opts);
677
778
  case "json":
678
- return await streamJson(input.filePath, dataSet.selector ?? "$", onRow, opts);
779
+ return await streamJson(input.filePath, dataSet.selector ?? "$", wrappedRow, opts);
679
780
  case "xlsx":
680
- return await streamXlsx(input.filePath, dataSet.label, onRow, opts);
781
+ return await streamXlsx(input.filePath, dataSet.label, wrappedRow, opts);
681
782
  case "xls":
682
- return await streamXls(input.filePath, dataSet.label, onRow, opts);
783
+ return await streamXls(input.filePath, dataSet.label, wrappedRow, opts);
683
784
  }
684
785
  } catch (error) {
685
786
  if (error instanceof CliValidationError) throw error;
787
+ if (error instanceof LocalDataRowCallbackError) {
788
+ throw error.cause instanceof Error ? error.cause : error;
789
+ }
686
790
  throw localDataParseError(input.format);
687
791
  }
688
792
  }
@@ -789,6 +893,21 @@ function peekDelimitedRecords(filePath, options = {}) {
789
893
  });
790
894
  return records.slice(0, options.limit ?? 10);
791
895
  }
896
+ var TITLE_ROW_SCAN_LIMIT = 3;
897
+ var TITLE_ROW_MIN_HEADER_WIDTH = 3;
898
+ function detectLeadingTitleRows(rows) {
899
+ const width = (row) => row.filter((value) => !isMissing(value)).length;
900
+ const titles = [];
901
+ for (let index = 0; index < Math.min(rows.length, TITLE_ROW_SCAN_LIMIT); index += 1) {
902
+ const cells = width(rows[index]);
903
+ if (cells > 1) break;
904
+ titles.push({ row: index + 1, non_empty_cells: cells });
905
+ }
906
+ if (titles.length === 0) return [];
907
+ const next = rows[titles.length];
908
+ if (!next || width(next) < TITLE_ROW_MIN_HEADER_WIDTH) return [];
909
+ return titles;
910
+ }
792
911
  function escapeRegex(source) {
793
912
  return source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
794
913
  }
@@ -799,21 +918,29 @@ function isNumeric(value) {
799
918
  }
800
919
  async function discoverDataSets(filePath, format) {
801
920
  if (format === "csv" || format === "tsv" || format === "jsonl") {
802
- return [{ id: "$", kind: "file", label: basename(filePath), selector: "$" }];
921
+ return { visible: [{ id: "$", kind: "file", label: basename(filePath), selector: "$" }], hidden: [] };
803
922
  }
804
923
  if (format === "xls") {
805
924
  const workbook = XLSX.readFile(filePath, { dense: true });
806
- return workbook.SheetNames.map((name) => ({ id: `sheet:${name}`, kind: "sheet", label: name, selector: name }));
925
+ return {
926
+ visible: workbook.SheetNames.map((name) => ({ id: `sheet:${name}`, kind: "sheet", label: name, selector: name })),
927
+ hidden: []
928
+ };
807
929
  }
808
930
  if (format === "xlsx") {
809
- return (await readXlsxSheetDefinitions(filePath)).map((sheet) => ({
931
+ const sheets = await readXlsxSheetDefinitions(filePath);
932
+ const toDataSet = (sheet) => ({
810
933
  id: `sheet:${sheet.name}`,
811
934
  kind: "sheet",
812
935
  label: sheet.name,
813
936
  selector: sheet.name
814
- }));
937
+ });
938
+ return {
939
+ visible: sheets.filter((sheet) => !sheet.hidden).map(toDataSet),
940
+ hidden: sheets.filter((sheet) => sheet.hidden).map(toDataSet)
941
+ };
815
942
  }
816
- return discoverJsonDataSets(filePath);
943
+ return { visible: await discoverJsonDataSets(filePath), hidden: [] };
817
944
  }
818
945
  async function discoverJsonDataSets(filePath) {
819
946
  const encoding = detectEncoding(filePath);
@@ -859,9 +986,11 @@ function childPath(parent) {
859
986
  async function streamDelimited(filePath, onRow, options) {
860
987
  const delimiter = options.delimiter ?? ",";
861
988
  const encoding = options.encoding ?? "utf-8";
989
+ const skipRows = Math.max(0, options.skipRows ?? 0);
862
990
  let headerNames = options.headerNames;
863
991
  if (!headerNames && options.noHeader) {
864
- const firstRecord = peekDelimitedRecords(filePath, { delimiter, encoding, limit: 1 })[0] ?? [];
992
+ const peeked = peekDelimitedRecords(filePath, { delimiter, encoding, limit: skipRows + 1 });
993
+ const firstRecord = peeked[skipRows] ?? [];
865
994
  headerNames = firstRecord.map((_, index) => `col_${index + 1}`);
866
995
  }
867
996
  const parser = parseCsv({
@@ -870,29 +999,41 @@ async function streamDelimited(filePath, onRow, options) {
870
999
  quote: delimiter === " " ? null : '"',
871
1000
  relax_column_count: true,
872
1001
  skip_empty_lines: true,
873
- trim: true,
874
- columns: headerNames ? headerNames : (headers) => dedupeHeaders(headers.map((header) => String(header).trim()))
1002
+ trim: true
875
1003
  });
876
1004
  decodeTextStream(filePath, encoding).pipe(parser);
877
1005
  let count = 0;
878
- for await (const value of parser) {
1006
+ let widthMismatches = 0;
1007
+ let skipped = 0;
1008
+ let resolvedHeaders = headerNames;
1009
+ for await (const raw of parser) {
1010
+ const values = raw;
1011
+ if (skipped < skipRows) {
1012
+ skipped += 1;
1013
+ continue;
1014
+ }
1015
+ if (!resolvedHeaders) {
1016
+ resolvedHeaders = dedupeHeaders(values.map((header) => String(header).trim()));
1017
+ continue;
1018
+ }
1019
+ if (values.length !== resolvedHeaders.length) widthMismatches += 1;
879
1020
  count += 1;
880
- const row = normalizeDelimitedRow(value, headerNames);
1021
+ const row = {};
1022
+ for (let index = 0; index < resolvedHeaders.length; index += 1) {
1023
+ row[resolvedHeaders[index]] = values[index] ?? null;
1024
+ }
881
1025
  await onRow(
882
- options.flattenRules && Object.keys(options.flattenRules).length > 0 ? flattenDelimitedRow(row, options.flattenRules) : row,
1026
+ options.flattenRules && Object.keys(options.flattenRules).length > 0 ? flattenDelimitedRow(row, options.flattenRules, options.flattenMisses) : row,
883
1027
  count
884
1028
  );
885
1029
  }
886
- return count;
887
- }
888
- function normalizeDelimitedRow(value, headerNames) {
889
- if (!headerNames || value === null || typeof value !== "object" || Array.isArray(value)) {
890
- return normalizeRow(value);
1030
+ if (widthMismatches > 0 && options.warnRagged !== false) {
1031
+ process.stderr.write(
1032
+ `Warning: ${widthMismatches} record(s) had a column count different from the header row; extra fields were dropped and missing fields were treated as empty.
1033
+ `
1034
+ );
891
1035
  }
892
- const source = value;
893
- const row = {};
894
- for (const header of headerNames) row[header] = source[header] ?? null;
895
- return row;
1036
+ return count;
896
1037
  }
897
1038
  async function streamJsonLines(filePath, onRow, options) {
898
1039
  let count = 0;
@@ -909,7 +1050,7 @@ async function streamJsonLines(filePath, onRow, options) {
909
1050
  location: { record: count }
910
1051
  });
911
1052
  }
912
- await onRow(flattenLocalDataRow(value, options.flattenRules), count);
1053
+ await onRow(flattenLocalDataRow(value, options.flattenRules, options.flattenMisses), count);
913
1054
  }
914
1055
  return count;
915
1056
  }
@@ -921,19 +1062,28 @@ async function streamJson(filePath, selector, onRow, options) {
921
1062
  const chain = selector === "$" || selector === "$object" ? source.pipe(parser).pipe(streamer) : source.pipe(parser).pipe(Pick.pick({ filter: selector })).pipe(streamer);
922
1063
  for await (const item of chain) {
923
1064
  count += 1;
924
- await onRow(flattenLocalDataRow(item.value, options.flattenRules), count);
1065
+ await onRow(flattenLocalDataRow(item.value, options.flattenRules, options.flattenMisses), count);
925
1066
  }
926
1067
  return count;
927
1068
  }
928
1069
  async function streamXlsx(filePath, sheetName, onRow, options) {
929
1070
  const sheetDefinitions = await readXlsxSheetDefinitions(filePath);
930
- const targets = options.mergeSheets ? sheetDefinitions : sheetDefinitions.filter((sheet) => sheet.name === sheetName);
1071
+ const targets = options.mergeSheets ? sheetDefinitions.filter((sheet) => !sheet.hidden) : sheetDefinitions.filter((sheet) => sheet.name === sheetName);
1072
+ if (options.mergeSheets) {
1073
+ const excluded = sheetDefinitions.filter((sheet) => sheet.hidden).map((sheet) => sheet.name);
1074
+ if (excluded.length > 0) {
1075
+ process.stderr.write(`Warning: skipped ${excluded.length} hidden worksheet(s): ${excluded.join(", ")}.
1076
+ `);
1077
+ }
1078
+ }
931
1079
  if (targets.length === 0) {
932
1080
  throw new CliValidationError("The requested Excel Sheet was not found.", { code: "LOCAL_DATA_SET_NOT_FOUND" });
933
1081
  }
934
1082
  const archive = await unzipper.Open.file(filePath);
935
- const sharedStringsEntry = archive.files.find((entry) => entry.path === "xl/sharedStrings.xml");
936
- const sharedStrings = sharedStringsEntry ? await readXlsxSharedStrings(sharedStringsEntry) : [];
1083
+ const workbook = await readXlsxWorkbookContext(archive.files);
1084
+ const skipRows = Math.max(0, options.skipRows ?? 0);
1085
+ const collector = options.xlsxStructure;
1086
+ if (collector) collector.filled = Boolean(options.fillMergedCells);
937
1087
  let count = 0;
938
1088
  for (const definition of targets) {
939
1089
  const worksheetEntry = archive.files.find((entry) => entry.path === definition.entryPath);
@@ -943,19 +1093,37 @@ async function streamXlsx(filePath, sheetName, onRow, options) {
943
1093
  location: { field: "input-file" }
944
1094
  });
945
1095
  }
946
- const worksheet = new ExcelWorksheetReader({
947
- workbook: {
948
- sharedStrings,
949
- styles: { getStyleModel: () => null },
950
- properties: { model: {} }
951
- },
1096
+ const structure = collector || options.fillMergedCells || options.excludeHiddenRows ? await readXlsxSheetStructure(worksheetEntry) : EMPTY_XLSX_SHEET_STRUCTURE;
1097
+ if (collector) {
1098
+ collector.mergedRanges += structure.merged.length;
1099
+ collector.hiddenRowCount += structure.hiddenRows.size;
1100
+ for (const range of structure.merged) {
1101
+ if (collector.mergedRefs.length < XLSX_STRUCTURE_SAMPLE_LIMIT) collector.mergedRefs.push(range.ref);
1102
+ }
1103
+ for (const rowNumber of structure.hiddenRows) {
1104
+ if (collector.hiddenRows.length < XLSX_STRUCTURE_SAMPLE_LIMIT) collector.hiddenRows.push(rowNumber);
1105
+ }
1106
+ }
1107
+ const merges = structure.merged.length > 0 ? new MergedRegionTracker(structure.merged, Boolean(options.fillMergedCells)) : void 0;
1108
+ const hiddenRows = options.excludeHiddenRows ? structure.hiddenRows : void 0;
1109
+ let hiddenColumnsResolved = false;
1110
+ const worksheet = new import_worksheet_reader.default({
1111
+ workbook,
952
1112
  id: definition.id,
953
1113
  iterator: worksheetEntry.stream(),
954
1114
  options: { worksheets: "emit", hyperlinks: "ignore" }
955
1115
  });
956
1116
  let headers = options.headerNames;
1117
+ let skipped = 0;
957
1118
  for await (const excelRow of worksheet) {
958
- const values = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => normalizeExcelValue(excelRow.getCell(index + 1).value));
1119
+ const rawValues = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => readExcelCellValue(excelRow.getCell(index + 1)));
1120
+ const reads = rawValues.map(readExcelCell);
1121
+ const values = reads.map((read) => read.value);
1122
+ merges?.observe(excelRow.number, values);
1123
+ if (skipped < skipRows) {
1124
+ skipped += 1;
1125
+ continue;
1126
+ }
959
1127
  if (options.noHeader && !headers) {
960
1128
  headers = values.map((_, index) => `col_${index + 1}`);
961
1129
  }
@@ -964,8 +1132,49 @@ async function streamXlsx(filePath, sheetName, onRow, options) {
964
1132
  continue;
965
1133
  }
966
1134
  if (values.every(isMissing)) continue;
1135
+ if (hiddenRows?.has(excelRow.number)) {
1136
+ if (collector) collector.excludedHiddenRows += 1;
1137
+ continue;
1138
+ }
1139
+ const covered = merges?.covered(excelRow.number, values);
967
1140
  count += 1;
968
- await onRow(Object.fromEntries(headers.map((header, index) => [header, values[index] ?? null])), count);
1141
+ if (collector) {
1142
+ if (!hiddenColumnsResolved) {
1143
+ hiddenColumnsResolved = true;
1144
+ for (const span of structure.hiddenColumns) {
1145
+ for (let column = span.min; column <= Math.min(span.max, headers.length); column += 1) {
1146
+ const header = headers[column - 1];
1147
+ if (header) collector.hiddenColumns.add(header);
1148
+ }
1149
+ }
1150
+ }
1151
+ for (const column of covered ?? []) {
1152
+ const header = headers[column - 1];
1153
+ if (!header) continue;
1154
+ collector.coveredCells.set(header, (collector.coveredCells.get(header) ?? 0) + 1);
1155
+ }
1156
+ }
1157
+ if (options.excelDateColumns) {
1158
+ rawValues.forEach((raw, index) => {
1159
+ const header = headers?.[index];
1160
+ if (header && isExcelDateCell(raw)) options.excelDateColumns?.add(header);
1161
+ });
1162
+ }
1163
+ const cellIssues = options.cellIssues;
1164
+ if (cellIssues) {
1165
+ reads.forEach((read, index) => {
1166
+ const header = headers?.[index];
1167
+ if (!read.issue || !header) return;
1168
+ const byColumn = cellIssues.get(read.issue) ?? /* @__PURE__ */ new Map();
1169
+ byColumn.set(header, (byColumn.get(header) ?? 0) + 1);
1170
+ cellIssues.set(read.issue, byColumn);
1171
+ });
1172
+ }
1173
+ const row = Object.fromEntries(headers.map((header, index) => [header, values[index] ?? null]));
1174
+ await onRow(
1175
+ options.flattenRules && Object.keys(options.flattenRules).length > 0 ? flattenDelimitedRow(row, options.flattenRules) : row,
1176
+ count
1177
+ );
969
1178
  }
970
1179
  }
971
1180
  return count;
@@ -980,28 +1189,29 @@ async function readXlsxSheetDefinitions(filePath) {
980
1189
  location: { field: "input-file" }
981
1190
  });
982
1191
  }
983
- const [workbookXml, relationshipsXml] = await Promise.all([
984
- workbookEntry.buffer().then((value) => value.toString("utf8")),
985
- relationshipsEntry.buffer().then((value) => value.toString("utf8"))
986
- ]);
987
1192
  const targets = /* @__PURE__ */ new Map();
988
- for (const match of relationshipsXml.matchAll(/<Relationship\b([^>]*)\/?\s*>/g)) {
989
- const attributes = parseXmlAttributes(match[1]);
990
- if (attributes.Id && attributes.Target) targets.set(attributes.Id, attributes.Target);
991
- }
1193
+ await parseXmlTags(relationshipsEntry, (tag) => {
1194
+ if (xmlLocalName(tag.name) !== "Relationship") return;
1195
+ const id = xmlAttributeByLocalName(tag.attributes, "Id");
1196
+ const target = xmlAttributeByLocalName(tag.attributes, "Target");
1197
+ if (id && target) targets.set(id, target);
1198
+ });
992
1199
  const sheets = [];
993
- for (const match of workbookXml.matchAll(/<sheet\b([^>]*)\/?\s*>/g)) {
994
- const attributes = parseXmlAttributes(match[1]);
995
- const relationshipId = attributes["r:id"];
1200
+ await parseXmlTags(workbookEntry, (tag) => {
1201
+ if (xmlLocalName(tag.name) !== "sheet") return;
1202
+ const relationshipId = xmlAttributeByLocalName(tag.attributes, "id");
996
1203
  const target = relationshipId ? targets.get(relationshipId) : void 0;
997
1204
  const fileNumber = target?.match(/worksheets\/sheet(\d+)\.xml$/)?.[1];
998
- if (!attributes.name || !relationshipId || !fileNumber) continue;
1205
+ const name = xmlAttributeByLocalName(tag.attributes, "name");
1206
+ if (!name || !relationshipId || !fileNumber) return;
1207
+ const state = xmlAttributeByLocalName(tag.attributes, "state");
999
1208
  sheets.push({
1000
- id: Number(attributes.sheetId ?? fileNumber),
1001
- name: decodeXml(attributes.name),
1002
- entryPath: normalizeXlsxEntryPath(target)
1209
+ id: Number(xmlAttributeByLocalName(tag.attributes, "sheetId") ?? fileNumber),
1210
+ name: decodeXml(name),
1211
+ entryPath: normalizeXlsxEntryPath(target),
1212
+ hidden: state === "hidden" || state === "veryHidden"
1003
1213
  });
1004
- }
1214
+ });
1005
1215
  if (sheets.length === 0) {
1006
1216
  throw new CliValidationError("XLSX workbook contains no readable Sheets.", {
1007
1217
  code: "LOCAL_DATA_XLSX_INVALID",
@@ -1024,25 +1234,21 @@ function readXlsSheetHeaders(filePath) {
1024
1234
  async function readXlsxSheetHeaders(filePath) {
1025
1235
  const sheetDefinitions = await readXlsxSheetDefinitions(filePath);
1026
1236
  const archive = await unzipper.Open.file(filePath);
1027
- const sharedStringsEntry = archive.files.find((entry) => entry.path === "xl/sharedStrings.xml");
1028
- const sharedStrings = sharedStringsEntry ? await readXlsxSharedStrings(sharedStringsEntry) : [];
1237
+ const workbook = await readXlsxWorkbookContext(archive.files);
1029
1238
  const sheets = [];
1030
1239
  for (const definition of sheetDefinitions) {
1240
+ if (definition.hidden) continue;
1031
1241
  const worksheetEntry = archive.files.find((entry) => entry.path === definition.entryPath);
1032
1242
  if (!worksheetEntry) continue;
1033
- const worksheet = new ExcelWorksheetReader({
1034
- workbook: {
1035
- sharedStrings,
1036
- styles: { getStyleModel: () => null },
1037
- properties: { model: {} }
1038
- },
1243
+ const worksheet = new import_worksheet_reader.default({
1244
+ workbook,
1039
1245
  id: definition.id,
1040
1246
  iterator: worksheetEntry.stream(),
1041
1247
  options: { worksheets: "emit", hyperlinks: "ignore" }
1042
1248
  });
1043
1249
  let headers = [];
1044
1250
  for await (const excelRow of worksheet) {
1045
- const values = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => normalizeExcelValue(excelRow.getCell(index + 1).value));
1251
+ const values = Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => readExcelCell(readExcelCellValue(excelRow.getCell(index + 1))).value);
1046
1252
  headers = firstRowHeaders(values);
1047
1253
  break;
1048
1254
  }
@@ -1053,13 +1259,82 @@ async function readXlsxSheetHeaders(filePath) {
1053
1259
  function firstRowHeaders(values) {
1054
1260
  return dedupeHeaders(values.map((value, index) => String(value ?? `column_${index + 1}`).trim()));
1055
1261
  }
1262
+ async function peekXlsxRows(filePath, sheetName, limit = 10) {
1263
+ const sheetDefinitions = await readXlsxSheetDefinitions(filePath);
1264
+ const definition = sheetDefinitions.find((sheet) => sheet.name === sheetName);
1265
+ if (!definition) return [];
1266
+ const archive = await unzipper.Open.file(filePath);
1267
+ const worksheetEntry = archive.files.find((entry) => entry.path === definition.entryPath);
1268
+ if (!worksheetEntry) return [];
1269
+ const workbook = await readXlsxWorkbookContext(archive.files);
1270
+ const worksheet = new import_worksheet_reader.default({
1271
+ workbook,
1272
+ id: definition.id,
1273
+ iterator: worksheetEntry.stream(),
1274
+ options: { worksheets: "emit", hyperlinks: "ignore" }
1275
+ });
1276
+ const rows = [];
1277
+ for await (const excelRow of worksheet) {
1278
+ rows.push(Array.from({ length: Math.max(0, excelRow.cellCount) }, (_, index) => readExcelCell(readExcelCellValue(excelRow.getCell(index + 1))).value));
1279
+ if (rows.length >= limit) break;
1280
+ }
1281
+ return rows;
1282
+ }
1283
+ var NO_XLSX_STYLES = { getStyleModel: () => null };
1284
+ var ELAPSED_TIME_FORMATS = /* @__PURE__ */ new Set(["mm:ss", "mmss.0", "[h]:mm:ss"]);
1285
+ function isElapsedTimeFormat(numFmt) {
1286
+ return ELAPSED_TIME_FORMATS.has(numFmt.trim()) || /\[[hms]+\]/i.test(numFmt);
1287
+ }
1288
+ async function readXlsxWorkbookContext(files) {
1289
+ const sharedStringsEntry = files.find((entry) => entry.path === "xl/sharedStrings.xml");
1290
+ const stylesEntry = files.find((entry) => entry.path === "xl/styles.xml");
1291
+ const workbookEntry = files.find((entry) => entry.path === "xl/workbook.xml");
1292
+ return {
1293
+ sharedStrings: sharedStringsEntry ? await readXlsxSharedStrings(sharedStringsEntry) : [],
1294
+ styles: await readXlsxStyles(stylesEntry),
1295
+ properties: { model: { date1904: workbookEntry ? await readXlsxDate1904(workbookEntry) : false } }
1296
+ };
1297
+ }
1298
+ async function readXlsxStyles(entry) {
1299
+ if (!entry) return NO_XLSX_STYLES;
1300
+ let styles;
1301
+ try {
1302
+ styles = new import_styles_xform.default();
1303
+ styles.init();
1304
+ await styles.parseStream(entry.stream());
1305
+ } catch {
1306
+ return NO_XLSX_STYLES;
1307
+ }
1308
+ return {
1309
+ getStyleModel: (id) => {
1310
+ let model;
1311
+ try {
1312
+ model = styles.getStyleModel(id);
1313
+ } catch {
1314
+ return null;
1315
+ }
1316
+ if (!model?.numFmt || !isElapsedTimeFormat(model.numFmt)) return model;
1317
+ const { numFmt, ...rest } = model;
1318
+ return rest;
1319
+ }
1320
+ };
1321
+ }
1322
+ async function readXlsxDate1904(entry) {
1323
+ let date1904 = false;
1324
+ await parseXmlTags(entry, (tag) => {
1325
+ if (xmlLocalName(tag.name) !== "workbookPr") return;
1326
+ const value = xmlAttributeByLocalName(tag.attributes, "date1904");
1327
+ date1904 = value === "1" || value === "true";
1328
+ });
1329
+ return date1904;
1330
+ }
1056
1331
  async function readXlsxSharedStrings(entry) {
1057
1332
  const values = [];
1058
1333
  let inItem = false;
1059
1334
  let current = "";
1060
1335
  const parser = new SaxesParser();
1061
1336
  parser.on("opentag", (tag) => {
1062
- if (tag.name === "si") {
1337
+ if (xmlLocalName(tag.name) === "si") {
1063
1338
  inItem = true;
1064
1339
  current = "";
1065
1340
  }
@@ -1068,26 +1343,147 @@ async function readXlsxSharedStrings(entry) {
1068
1343
  if (inItem) current += text;
1069
1344
  });
1070
1345
  parser.on("closetag", (tag) => {
1071
- if (tag.name === "si") {
1346
+ if (xmlLocalName(tag.name) === "si") {
1072
1347
  values.push(current);
1073
1348
  inItem = false;
1074
1349
  current = "";
1075
1350
  }
1076
1351
  });
1352
+ const decoder = new StringDecoder("utf8");
1077
1353
  for await (const chunk of entry.stream()) {
1078
- parser.write(Buffer.from(chunk).toString("utf8"));
1354
+ parser.write(decoder.write(Buffer.from(chunk)));
1079
1355
  }
1356
+ parser.write(decoder.end());
1080
1357
  parser.close();
1081
1358
  return values;
1082
1359
  }
1360
+ var EMPTY_XLSX_SHEET_STRUCTURE = {
1361
+ merged: [],
1362
+ hiddenRows: /* @__PURE__ */ new Set(),
1363
+ hiddenColumns: []
1364
+ };
1365
+ async function readXlsxSheetStructure(entry) {
1366
+ const merged = [];
1367
+ const hiddenRows = /* @__PURE__ */ new Set();
1368
+ const hiddenColumns = [];
1369
+ try {
1370
+ const parser = new SaxesParser();
1371
+ parser.on("opentag", (tag) => {
1372
+ const attributes = tag.attributes;
1373
+ if (xmlLocalName(tag.name) === "mergeCell") {
1374
+ const range = parseMergedRef(attributes.ref ?? "");
1375
+ if (range) merged.push(range);
1376
+ return;
1377
+ }
1378
+ if (xmlLocalName(tag.name) === "row") {
1379
+ const rowNumber = Number(attributes.r);
1380
+ if (isXlsxFlagSet(attributes.hidden) && Number.isInteger(rowNumber) && rowNumber >= 1) {
1381
+ hiddenRows.add(rowNumber);
1382
+ }
1383
+ return;
1384
+ }
1385
+ if (xmlLocalName(tag.name) === "col" && isXlsxFlagSet(attributes.hidden)) {
1386
+ const min = Number(attributes.min);
1387
+ const max = Number(attributes.max);
1388
+ if (Number.isInteger(min) && min >= 1) {
1389
+ hiddenColumns.push({ min, max: Number.isInteger(max) && max >= min ? max : min });
1390
+ }
1391
+ }
1392
+ });
1393
+ const decoder = new StringDecoder("utf8");
1394
+ for await (const chunk of entry.stream()) {
1395
+ parser.write(decoder.write(Buffer.from(chunk)));
1396
+ }
1397
+ parser.write(decoder.end());
1398
+ parser.close();
1399
+ } catch {
1400
+ return EMPTY_XLSX_SHEET_STRUCTURE;
1401
+ }
1402
+ return { merged, hiddenRows, hiddenColumns };
1403
+ }
1404
+ function isXlsxFlagSet(value) {
1405
+ return value === "1" || value === "true";
1406
+ }
1407
+ function parseMergedRef(ref) {
1408
+ const match = /^([A-Za-z]+)(\d+):([A-Za-z]+)(\d+)$/.exec(ref.trim());
1409
+ if (!match) return void 0;
1410
+ const left = xlsxColumnNumber(match[1]);
1411
+ const top = Number(match[2]);
1412
+ const right = xlsxColumnNumber(match[3]);
1413
+ const bottom = Number(match[4]);
1414
+ if (right < left || bottom < top || top < 1) return void 0;
1415
+ return { ref: ref.trim().toUpperCase(), top, bottom, left, right };
1416
+ }
1417
+ function xlsxColumnNumber(letters) {
1418
+ let column = 0;
1419
+ for (const letter of letters.toUpperCase()) column = column * 26 + (letter.charCodeAt(0) - 64);
1420
+ return column;
1421
+ }
1422
+ var MergedRegionTracker = class {
1423
+ constructor(ranges, fill) {
1424
+ this.fill = fill;
1425
+ this.pending = [...ranges].sort((left, right) => left.top - right.top || left.left - right.left);
1426
+ }
1427
+ fill;
1428
+ pending;
1429
+ next = 0;
1430
+ active = [];
1431
+ /** Advance to `rowNumber`, taking the anchor value of every range that starts on it. */
1432
+ observe(rowNumber, values) {
1433
+ while (this.next < this.pending.length && this.pending[this.next].top <= rowNumber) {
1434
+ this.active.push({ range: this.pending[this.next], anchor: void 0 });
1435
+ this.next += 1;
1436
+ }
1437
+ if (this.active.length === 0) return;
1438
+ this.active = this.active.filter((entry) => entry.range.bottom >= rowNumber);
1439
+ for (const entry of this.active) {
1440
+ if (entry.range.top === rowNumber) entry.anchor = values[entry.range.left - 1];
1441
+ }
1442
+ }
1443
+ /**
1444
+ * The 1-based columns whose cell on this row is empty only because a merge covers it, filled from
1445
+ * the anchor when enabled. `observe` must have run for the same row first.
1446
+ */
1447
+ covered(rowNumber, values) {
1448
+ const columns = [];
1449
+ for (const entry of this.active) {
1450
+ if (isMissing(entry.anchor)) continue;
1451
+ const { range } = entry;
1452
+ for (let column = range.left; column <= range.right; column += 1) {
1453
+ if (range.top === rowNumber && column === range.left) continue;
1454
+ if (!isMissing(values[column - 1])) continue;
1455
+ columns.push(column);
1456
+ if (this.fill) values[column - 1] = entry.anchor;
1457
+ }
1458
+ }
1459
+ return columns;
1460
+ }
1461
+ };
1083
1462
  function normalizeXlsxEntryPath(target) {
1084
1463
  const normalized = target.replace(/^\//, "");
1085
1464
  return normalized.startsWith("xl/") ? normalized : `xl/${normalized.replace(/^\.\//, "")}`;
1086
1465
  }
1087
- function parseXmlAttributes(source) {
1088
- const attributes = {};
1089
- for (const match of source.matchAll(/([\w:-]+)="([^"]*)"/g)) attributes[match[1]] = match[2];
1090
- return attributes;
1466
+ function xmlLocalName(qname) {
1467
+ const colon = qname.lastIndexOf(":");
1468
+ return colon === -1 ? qname : qname.slice(colon + 1);
1469
+ }
1470
+ function xmlAttributeByLocalName(attributes, local) {
1471
+ for (const [name, value] of Object.entries(attributes)) {
1472
+ if (xmlLocalName(name) === local) return value;
1473
+ }
1474
+ return void 0;
1475
+ }
1476
+ async function parseXmlTags(entry, onOpenTag) {
1477
+ const parser = new SaxesParser();
1478
+ parser.on("opentag", (tag) => {
1479
+ onOpenTag({ name: tag.name, attributes: tag.attributes });
1480
+ });
1481
+ const decoder = new StringDecoder("utf8");
1482
+ for await (const chunk of entry.stream()) {
1483
+ parser.write(decoder.write(Buffer.from(chunk)));
1484
+ }
1485
+ parser.write(decoder.end());
1486
+ parser.close();
1091
1487
  }
1092
1488
  function decodeXml(source) {
1093
1489
  return source.replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
@@ -1099,10 +1495,12 @@ async function streamXls(filePath, sheetName, onRow, options) {
1099
1495
  throw new CliValidationError("The requested Excel Sheet was not found.", { code: "LOCAL_DATA_SET_NOT_FOUND" });
1100
1496
  }
1101
1497
  let count = 0;
1498
+ const skipRows = Math.max(0, options.skipRows ?? 0);
1102
1499
  for (const name of names) {
1103
1500
  const sheet = workbook.Sheets[name];
1104
1501
  if (!sheet) continue;
1105
- const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: true, defval: null });
1502
+ const allRows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: true, defval: null });
1503
+ const rows = allRows.slice(skipRows);
1106
1504
  if (rows.length === 0) continue;
1107
1505
  let headers = options.headerNames;
1108
1506
  let start = 0;
@@ -1117,27 +1515,136 @@ async function streamXls(filePath, sheetName, onRow, options) {
1117
1515
  for (const values of rows.slice(start)) {
1118
1516
  if (values.every(isMissing)) continue;
1119
1517
  count += 1;
1120
- await onRow(Object.fromEntries(headers.map((header, index) => [header, values[index] ?? null])), count);
1518
+ const row = Object.fromEntries(headers.map((header, index) => [header, values[index] ?? null]));
1519
+ await onRow(
1520
+ options.flattenRules && Object.keys(options.flattenRules).length > 0 ? flattenDelimitedRow(row, options.flattenRules) : row,
1521
+ count
1522
+ );
1121
1523
  }
1122
1524
  }
1123
1525
  return count;
1124
1526
  }
1125
- function normalizeRow(value) {
1126
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1127
- return value;
1527
+ function excelDateToWallClock(value) {
1528
+ const pad = (part, width = 2) => String(part).padStart(width, "0");
1529
+ const date = `${value.getUTCFullYear()}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())}`;
1530
+ const [hours, minutes, seconds, ms] = [
1531
+ value.getUTCHours(),
1532
+ value.getUTCMinutes(),
1533
+ value.getUTCSeconds(),
1534
+ value.getUTCMilliseconds()
1535
+ ];
1536
+ if (hours === 0 && minutes === 0 && seconds === 0 && ms === 0) return date;
1537
+ const time = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
1538
+ return ms === 0 ? `${date} ${time}` : `${date} ${time}.${pad(ms, 3)}`;
1539
+ }
1540
+ function isExcelDateCell(value) {
1541
+ if (value instanceof Date) return true;
1542
+ return Boolean(value && typeof value === "object" && "result" in value && value.result instanceof Date);
1543
+ }
1544
+ function readExcelCellValue(cell) {
1545
+ if (cell.type !== import_exceljs.default.ValueType.Formula) return cell.value;
1546
+ const model = cell.model;
1547
+ if (!model || !("result" in model)) return cell.value;
1548
+ return { formula: model.formula ?? "", result: model.result };
1549
+ }
1550
+ var CELL_ISSUE_ORDER = ["formula_no_cached_value", "error_value", "unreadable_object"];
1551
+ var CELL_ISSUE_MESSAGES = {
1552
+ formula_no_cached_value: "hold a formula whose last computed result is not stored in the file, and were read as missing. Excel normally saves that result next to the formula; a file exported without it has no value to upload. Recalculate and re-export in Excel, or export values instead of formulas. This tool never evaluates a formula and never guesses a result.",
1553
+ error_value: "hold an Excel error value (#N/A, #DIV/0!, \u2026), and were read as missing. Fix them in the source file if those cells were meant to carry data.",
1554
+ unreadable_object: "arrived in a cell shape this tool does not recognize, and were read as missing rather than passed on as an object. Report the file if these cells do carry data."
1555
+ };
1556
+ function cellIssueWarnings(cellIssues) {
1557
+ const warnings = [];
1558
+ for (const issue of CELL_ISSUE_ORDER) {
1559
+ const byColumn = cellIssues.get(issue);
1560
+ if (!byColumn || byColumn.size === 0) continue;
1561
+ const columns = [...byColumn.entries()].map(([column, count]) => `${column} (${count})`).join(", ");
1562
+ const total = [...byColumn.values()].reduce((sum, count) => sum + count, 0);
1563
+ warnings.push(`${total} cell(s) ${CELL_ISSUE_MESSAGES[issue]} Affected columns: ${columns}.`);
1564
+ }
1565
+ return warnings;
1566
+ }
1567
+ function cellIssueCounts(cellIssues) {
1568
+ const counts = {};
1569
+ for (const issue of CELL_ISSUE_ORDER) {
1570
+ const byColumn = cellIssues.get(issue);
1571
+ if (!byColumn || byColumn.size === 0) continue;
1572
+ counts[issue] = Object.fromEntries(byColumn);
1573
+ }
1574
+ return counts;
1575
+ }
1576
+ var XLSX_STRUCTURE_SAMPLE_LIMIT = 10;
1577
+ function createXlsxStructureCollector() {
1578
+ return {
1579
+ mergedRanges: 0,
1580
+ mergedRefs: [],
1581
+ coveredCells: /* @__PURE__ */ new Map(),
1582
+ filled: false,
1583
+ hiddenRows: [],
1584
+ hiddenRowCount: 0,
1585
+ excludedHiddenRows: 0,
1586
+ hiddenColumns: /* @__PURE__ */ new Set()
1587
+ };
1588
+ }
1589
+ function xlsxStructureReport(collector) {
1590
+ const hasFindings = collector.mergedRanges > 0 || collector.hiddenRowCount > 0 || collector.hiddenColumns.size > 0;
1591
+ if (!hasFindings) return void 0;
1592
+ return {
1593
+ ...collector.mergedRanges > 0 ? {
1594
+ merged_ranges: collector.mergedRanges,
1595
+ merged_range_samples: collector.mergedRefs,
1596
+ merged_covered_cells: Object.fromEntries(collector.coveredCells),
1597
+ merged_cells_filled: collector.filled
1598
+ } : {},
1599
+ ...collector.hiddenRowCount > 0 ? {
1600
+ hidden_rows: collector.hiddenRowCount,
1601
+ hidden_row_samples: collector.hiddenRows,
1602
+ excluded_hidden_rows: collector.excludedHiddenRows
1603
+ } : {},
1604
+ ...collector.hiddenColumns.size > 0 ? { hidden_columns: [...collector.hiddenColumns] } : {}
1605
+ };
1606
+ }
1607
+ function xlsxStructureWarnings(collector, excludedColumns) {
1608
+ const warnings = [];
1609
+ const sample = (values, total) => values.join(", ") + (total > values.length ? ", \u2026" : "");
1610
+ if (collector.coveredCells.size > 0) {
1611
+ const columns = [...collector.coveredCells.entries()].map(([column, count]) => `${column} (${count})`).join(", ");
1612
+ const total = [...collector.coveredCells.values()].reduce((sum, count) => sum + count, 0);
1613
+ warnings.push(collector.filled ? `${total} cell(s) were empty only because a merged block covers them, and were filled from the value on the block's first row. Affected columns: ${columns}.` : `${total} cell(s) are empty only because a merged block covers them: Excel shows the value on the block's first row only, so every row below it reads as missing. Affected columns: ${columns}. Pass --fill-merged-cells to copy each block's value down its own range, or unmerge and fill the column in the source file.`);
1614
+ }
1615
+ if (collector.hiddenRowCount > 0) {
1616
+ const rows = sample(collector.hiddenRows, collector.hiddenRowCount);
1617
+ warnings.push(collector.excludedHiddenRows > 0 ? `Skipped ${collector.excludedHiddenRows} row(s) hidden in the source worksheet (source row ${rows}).` : `${collector.hiddenRowCount} row(s) are hidden in the source worksheet (source row ${rows}) and were read as data. Pass --exclude-hidden-rows to leave them out.`);
1618
+ }
1619
+ if (collector.hiddenColumns.size > 0) {
1620
+ const excluded = new Set(excludedColumns ?? []);
1621
+ const kept = [...collector.hiddenColumns].filter((column) => !excluded.has(column));
1622
+ if (kept.length > 0) {
1623
+ warnings.push(`${kept.length} column(s) are hidden in the source worksheet and were read as data: ${kept.join(", ")}. List them in the mapping's exclude_columns to leave them out.`);
1624
+ }
1128
1625
  }
1129
- return { value };
1626
+ return warnings;
1130
1627
  }
1131
- function normalizeExcelValue(value) {
1132
- if (value instanceof Date) return value;
1628
+ function readExcelCell(value) {
1629
+ if (value instanceof Date) return { value: excelDateToWallClock(value) };
1133
1630
  if (value && typeof value === "object") {
1134
- if ("result" in value) return value.result ?? null;
1135
- if ("text" in value) return String(value.text);
1631
+ if ("error" in value) return { value: null, issue: "error_value" };
1632
+ if ("formula" in value || "sharedFormula" in value) {
1633
+ if (!("result" in value)) return { value: null, issue: "formula_no_cached_value" };
1634
+ const result = value.result;
1635
+ if (result === null || result === void 0) return { value: null, issue: "formula_no_cached_value" };
1636
+ if (typeof result === "object" && "error" in result) return { value: null, issue: "error_value" };
1637
+ if (typeof result === "number" && Number.isNaN(result)) return { value: null, issue: "error_value" };
1638
+ if (result instanceof Date) return { value: excelDateToWallClock(result) };
1639
+ return { value: result };
1640
+ }
1641
+ if ("text" in value) return { value: String(value.text) };
1136
1642
  if ("richText" in value) {
1137
- return value.richText.map((part) => part.text).join("");
1643
+ return { value: value.richText.map((part) => part.text).join("") };
1138
1644
  }
1645
+ return { value: null, issue: "unreadable_object" };
1139
1646
  }
1140
- return value ?? null;
1647
+ return { value: value ?? null };
1141
1648
  }
1142
1649
  function dedupeHeaders(headers) {
1143
1650
  const counts = /* @__PURE__ */ new Map();
@@ -1170,9 +1677,19 @@ async function firstNonWhitespaceCharacter(filePath, encoding) {
1170
1677
  return void 0;
1171
1678
  }
1172
1679
 
1173
- // src/commands/data-integration/local-data/mapping.ts
1680
+ // src/commands/data-integration/mapping.ts
1174
1681
  import { readFileSync } from "fs";
1682
+
1683
+ // src/commands/data-integration/types.ts
1684
+ var MAPPING_VERSION = "ae-data-integration-mapping/v1";
1685
+
1686
+ // src/commands/data-integration/mapping.ts
1175
1687
  var VALID_PROPERTY_NAME = /^[a-z][a-z0-9_]{0,49}$/;
1688
+ var VALID_CHILD_PROPERTY_NAME = /^[a-z][a-z0-9_]{0,49}(\.[a-z][a-z0-9_]{0,49})+$/;
1689
+ var FLATTEN_SUPPORTED_FORMATS = ["csv", "tsv", "json", "jsonl", "xls", "xlsx"];
1690
+ var SKIP_ROWS_SUPPORTED_FORMATS = ["csv", "tsv", "xls", "xlsx"];
1691
+ var XLSX_STRUCTURE_SUPPORTED_FORMATS = ["xlsx"];
1692
+ var XLSX_STRUCTURE_FIELDS = ["fill_merged_cells", "exclude_hidden_rows"];
1176
1693
  function readLocalDataMapping(raw, options) {
1177
1694
  const trimmed = raw.trim();
1178
1695
  let text;
@@ -1202,8 +1719,8 @@ function readLocalDataMapping(raw, options) {
1202
1719
  return value;
1203
1720
  }
1204
1721
  function validateMapping(value, options) {
1205
- if (!isRecord(value) || value.version !== "ae-local-data-mapping/v1") {
1206
- throw mappingError("Mapping version must be ae-local-data-mapping/v1.");
1722
+ if (!isRecord(value) || value.version !== MAPPING_VERSION) {
1723
+ throw mappingError(`Mapping version must be ${MAPPING_VERSION}.`);
1207
1724
  }
1208
1725
  const sha256Valid = typeof value.source?.sha256 === "string" && (options?.sourceWildcard ? value.source.sha256 === "*" || /^[a-f0-9]{64}$/i.test(value.source.sha256) : /^[a-f0-9]{64}$/i.test(value.source.sha256));
1209
1726
  if (!isRecord(value.source) || !sha256Valid || !["csv", "tsv", "json", "jsonl", "xls", "xlsx"].includes(String(value.source.format)) || typeof value.source.data_set !== "string" || !value.source.data_set) {
@@ -1233,12 +1750,28 @@ function validateMapping(value, options) {
1233
1750
  }
1234
1751
  if (!Array.isArray(value.properties)) throw mappingError("Mapping properties must be an array.");
1235
1752
  const targets = /* @__PURE__ */ new Set();
1753
+ const propertyTypes = /* @__PURE__ */ new Map();
1236
1754
  for (const property of value.properties) {
1237
- if (!isRecord(property) || typeof property.source !== "string" || typeof property.target !== "string" || !VALID_PROPERTY_NAME.test(property.target) || !["number", "string", "boolean", "datetime", "list", "object"].includes(String(property.type)) || property.transform !== void 0 && !["stringify", "number", "boolean", "json"].includes(String(property.transform)) || property.value_mapping !== void 0 && !isStringMap(property.value_mapping) || property.time_format !== void 0 && (typeof property.time_format !== "string" || !property.time_format.trim() || property.time_format.length > 64) || property.desc !== void 0 && (typeof property.desc !== "string" || !property.desc.trim() || property.desc.length > 200)) {
1755
+ if (!isRecord(property) || typeof property.source !== "string" || typeof property.target !== "string" || !(VALID_PROPERTY_NAME.test(property.target) || VALID_CHILD_PROPERTY_NAME.test(property.target)) || !["number", "string", "boolean", "datetime", "list", "object", "array_row"].includes(String(property.type)) || property.transform !== void 0 && !["stringify", "number", "boolean", "json"].includes(String(property.transform)) || property.value_mapping !== void 0 && !isStringMap(property.value_mapping) || property.time_format !== void 0 && (typeof property.time_format !== "string" || !property.time_format.trim() || property.time_format.length > 64) || property.desc !== void 0 && (typeof property.desc !== "string" || !property.desc.trim() || property.desc.length > 200)) {
1238
1756
  throw mappingError("Every property mapping needs a source, legal AE target name, and supported type.");
1239
1757
  }
1240
1758
  if (targets.has(property.target)) throw mappingError("Property target names must be unique.");
1241
1759
  targets.add(property.target);
1760
+ propertyTypes.set(property.target, { type: String(property.type), source: property.source });
1761
+ }
1762
+ for (const property of value.properties) {
1763
+ if (!property.target.includes(".")) continue;
1764
+ const parentName = property.target.split(".")[0];
1765
+ const parent = propertyTypes.get(parentName);
1766
+ if (!parent || parent.type !== "object" && parent.type !== "array_row") {
1767
+ throw mappingError(`The sub-property "${property.target}" references "${parentName}", which is not an object/array_row property in this mapping.`);
1768
+ }
1769
+ if (property.type === "object" || property.type === "array_row") {
1770
+ throw mappingError(`The sub-property "${property.target}" must be scalar or list, not ${property.type}.`);
1771
+ }
1772
+ if (property.source !== parent.source) {
1773
+ throw mappingError(`The sub-property "${property.target}" must read from the same source column as its parent "${parentName}".`);
1774
+ }
1242
1775
  }
1243
1776
  if (value.time_format !== void 0 && (typeof value.time_format !== "string" || !value.time_format.trim() || value.time_format.length > 64)) {
1244
1777
  throw mappingError("time_format must be a non-empty string of at most 64 characters.");
@@ -1280,6 +1813,9 @@ function validateMapping(value, options) {
1280
1813
  }
1281
1814
  if (value.flatten_rules !== void 0) {
1282
1815
  if (!isRecord(value.flatten_rules)) throw mappingError("flatten_rules must be an object of { column: dot.path }.");
1816
+ if (!FLATTEN_SUPPORTED_FORMATS.includes(String(value.source.format))) {
1817
+ throw mappingError(`flatten_rules are not supported for ${String(value.source.format)} input.`);
1818
+ }
1283
1819
  for (const [column, path] of Object.entries(value.flatten_rules)) {
1284
1820
  if (!VALID_PROPERTY_NAME.test(column) || typeof path !== "string" || !path.trim()) {
1285
1821
  throw mappingError("flatten_rules keys must be legal AE property names and values must be non-empty dot paths.");
@@ -1289,6 +1825,23 @@ function validateMapping(value, options) {
1289
1825
  if (value.headers !== void 0 && !isNonEmptyStringArray(value.headers, true)) {
1290
1826
  throw mappingError("headers must be a non-empty array of unique strings.");
1291
1827
  }
1828
+ if (value.skip_rows !== void 0) {
1829
+ if (typeof value.skip_rows !== "number" || !Number.isInteger(value.skip_rows) || value.skip_rows < 1) {
1830
+ throw mappingError("skip_rows must be a positive integer.");
1831
+ }
1832
+ if (!SKIP_ROWS_SUPPORTED_FORMATS.includes(String(value.source.format))) {
1833
+ throw mappingError(`skip_rows is not supported for ${String(value.source.format)} input.`);
1834
+ }
1835
+ }
1836
+ for (const field of XLSX_STRUCTURE_FIELDS) {
1837
+ if (value[field] === void 0) continue;
1838
+ if (typeof value[field] !== "boolean") {
1839
+ throw mappingError(`${field} must be a boolean when provided.`);
1840
+ }
1841
+ if (!XLSX_STRUCTURE_SUPPORTED_FORMATS.includes(String(value.source.format))) {
1842
+ throw mappingError(`${field} is not supported for ${String(value.source.format)} input.`);
1843
+ }
1844
+ }
1292
1845
  if (value.missing_time !== void 0 && value.missing_time !== "now") {
1293
1846
  throw mappingError('missing_time must be "now" when provided.');
1294
1847
  }
@@ -1323,6 +1876,31 @@ function validateMapping(value, options) {
1323
1876
  function isValidAeName(value) {
1324
1877
  return VALID_PROPERTY_NAME.test(value);
1325
1878
  }
1879
+ function sourceColumns(mapping) {
1880
+ const columns = /* @__PURE__ */ new Set();
1881
+ const flattenOut = new Set(Object.keys(mapping.flatten_rules ?? {}));
1882
+ for (const property of mapping.properties) {
1883
+ if (!flattenOut.has(property.source)) columns.add(property.source);
1884
+ }
1885
+ for (const path of Object.values(mapping.flatten_rules ?? {})) {
1886
+ const root = path.split(".")[0];
1887
+ if (root) columns.add(root);
1888
+ }
1889
+ const systemFields = [
1890
+ mapping.account_id_field,
1891
+ mapping.distinct_id_field,
1892
+ mapping.record_type_field,
1893
+ mapping.event_name_field,
1894
+ mapping.time.field,
1895
+ mapping.ip_field,
1896
+ mapping.uuid_field,
1897
+ mapping.zone_offset_field
1898
+ ];
1899
+ for (const field of systemFields) if (field) columns.add(field);
1900
+ for (const column of mapping.exclude_columns ?? []) columns.add(column);
1901
+ for (const header of mapping.headers ?? []) columns.add(header);
1902
+ return [...columns].sort();
1903
+ }
1326
1904
  function mappingError(message) {
1327
1905
  return new CliValidationError(message, {
1328
1906
  code: "LOCAL_DATA_MAPPING_INVALID",
@@ -1349,7 +1927,7 @@ function isRecord(value) {
1349
1927
  return value !== null && typeof value === "object" && !Array.isArray(value);
1350
1928
  }
1351
1929
 
1352
- // src/commands/data-integration/local-data/multi.ts
1930
+ // src/commands/data-integration/multi.ts
1353
1931
  var PROPERTY_TYPES = /* @__PURE__ */ new Set([
1354
1932
  "number",
1355
1933
  "string",
@@ -1494,14 +2072,63 @@ function isRecord2(value) {
1494
2072
  return value !== null && typeof value === "object" && !Array.isArray(value);
1495
2073
  }
1496
2074
 
1497
- // src/commands/data-integration/local-data/profile.ts
2075
+ // src/commands/data-integration/profile.ts
1498
2076
  import { createHash as createHash2, randomInt } from "crypto";
1499
2077
  import { basename as basename2, extname as extname2 } from "path";
2078
+
2079
+ // src/commands/data-integration/field-spec.ts
2080
+ import { isIP } from "net";
2081
+ function stripQuotes(value) {
2082
+ if (typeof value !== "string") return value;
2083
+ const text = value;
2084
+ if (text.length >= 2 && (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'"))) {
2085
+ return text.slice(1, -1).trim();
2086
+ }
2087
+ return text.trim();
2088
+ }
2089
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2090
+ function isValidUuid(value) {
2091
+ if (typeof value !== "string") return false;
2092
+ return UUID_PATTERN.test(stripQuotes(value));
2093
+ }
2094
+ function isValidIp(value) {
2095
+ if (typeof value !== "string") return false;
2096
+ return isIP(stripQuotes(value)) !== 0;
2097
+ }
2098
+ function isPrivateIp(value) {
2099
+ if (typeof value !== "string") return false;
2100
+ const text = stripQuotes(value);
2101
+ const version = isIP(text);
2102
+ if (version === 6) {
2103
+ const lower = text.toLowerCase();
2104
+ if (lower === "::1") return true;
2105
+ const first2 = lower.split(":")[0];
2106
+ return first2.startsWith("fc") || first2.startsWith("fd") || /^fe[89ab]/.test(lower);
2107
+ }
2108
+ if (version !== 4) return false;
2109
+ const octets = text.split(".").map((part) => Number(part));
2110
+ const [first, second] = octets;
2111
+ return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 127 || first === 169 && second === 254;
2112
+ }
2113
+
2114
+ // src/commands/data-integration/profile.ts
1500
2115
  var UNIQUE_SAMPLE_LIMIT = 1e4;
1501
2116
  var GLOBAL_UNIQUE_SAMPLE_LIMIT = 2e5;
1502
2117
  var IDENTITY_MAX_LENGTH = 128;
1503
2118
  var COLUMN_SAMPLE_LIMIT = 5;
1504
2119
  var SAMPLE_TRUNCATE_LENGTH = 40;
2120
+ var VALUE_FREQUENCY_LIMIT = 200;
2121
+ var VALUE_FREQUENCY_TOP = 10;
2122
+ var NUMERIC_SAMPLE_LIMIT = 5e3;
2123
+ var SUMMARY_ROW_CANDIDATE_LIMIT = 50;
2124
+ var SUMMARY_ROW_WARNING_LIMIT = 10;
2125
+ var SUMMARY_LABEL_MAX_LENGTH = 24;
2126
+ var SUMMARY_LABELS_CJK = ["\u5408\u8BA1", "\u603B\u8BA1", "\u5C0F\u8BA1", "\u6C47\u603B"];
2127
+ var SUMMARY_LABELS_LATIN = /* @__PURE__ */ new Set(["total", "totals", "subtotal", "sub total", "grand total", "sum"]);
2128
+ var DUPLICATE_KEY_TRACK_LIMIT = 2e5;
2129
+ var DUPLICATE_GROUP_LIMIT = 20;
2130
+ var DUPLICATE_GROUP_ROW_LIMIT = 10;
2131
+ var DUPLICATE_WARNING_GROUP_LIMIT = 3;
1505
2132
  var NESTED_TREE_SAMPLE_LIMIT = 1e3;
1506
2133
  var USER_PROFILE_TYPES = /* @__PURE__ */ new Set([
1507
2134
  "user_set",
@@ -1520,6 +2147,8 @@ var DISTINCT_NAMES = ["#distinct_id", "distinct_id", "distinctid", "device_id",
1520
2147
  var TIME_NAMES = ["#time", "time", "timestamp", "event_time", "created_at", "occurred_at", "datetime", "date", "\u65F6\u95F4", "\u4E8B\u4EF6\u65F6\u95F4", "\u53D1\u751F\u65F6\u95F4", "\u521B\u5EFA\u65F6\u95F4", "\u4E0B\u5355\u65F6\u95F4", "\u8BA2\u5355\u65F6\u95F4"];
1521
2148
  var EVENT_NAMES = ["#event_name", "event_name", "event", "action", "activity", "\u4E8B\u4EF6\u540D", "\u4E8B\u4EF6\u540D\u79F0", "\u4E8B\u4EF6", "\u884C\u4E3A", "\u52A8\u4F5C"];
1522
2149
  var TYPE_NAMES = ["#type", "record_type", "data_type", "\u64CD\u4F5C\u7C7B\u578B"];
2150
+ var IP_NAMES = ["#ip", "ip", "ip_address", "ipaddress", "client_ip", "clientip", "remote_addr", "remoteaddr", "ip\u5730\u5740", "\u5BA2\u6237\u7AEFip"];
2151
+ var UUID_NAMES = ["#uuid", "uuid", "event_uuid", "eventuuid", "request_uuid", "requestuuid", "\u552F\u4E00\u6807\u8BC6", "\u552F\u4E00id"];
1523
2152
  async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai", options = {}) {
1524
2153
  const columns = /* @__PURE__ */ new Map();
1525
2154
  const recognizedRecordTypes = /* @__PURE__ */ new Set();
@@ -1529,15 +2158,22 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1529
2158
  options.collectNestedTree && (input.format === "json" || input.format === "jsonl")
1530
2159
  );
1531
2160
  const collectDelimitedTree = Boolean(
1532
- options.collectNestedTree && (input.format === "csv" || input.format === "tsv")
2161
+ options.collectNestedTree && (input.format === "csv" || input.format === "tsv" || input.format === "xlsx" || input.format === "xls")
1533
2162
  );
1534
2163
  let nestedObjects = [];
1535
2164
  let nestedSeen = 0;
1536
2165
  const delimitedNested = /* @__PURE__ */ new Map();
2166
+ const excelDateColumns = /* @__PURE__ */ new Set();
2167
+ const cellIssues = /* @__PURE__ */ new Map();
2168
+ const xlsxStructure = input.format === "xlsx" ? createXlsxStructureCollector() : void 0;
2169
+ const summaryCandidates = [];
2170
+ let summaryCandidatesTruncated = false;
2171
+ let duplicateKeys;
2172
+ let duplicateKeysResolved = false;
1537
2173
  await streamLocalDataRows(
1538
2174
  input,
1539
2175
  dataSet,
1540
- (row) => {
2176
+ (row, rowNumber) => {
1541
2177
  rowCount += 1;
1542
2178
  if (collectNestedTree && row !== null && typeof row === "object" && !Array.isArray(row)) {
1543
2179
  nestedSeen += 1;
@@ -1548,6 +2184,10 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1548
2184
  if (slot < NESTED_TREE_SAMPLE_LIMIT) nestedObjects[slot] = row;
1549
2185
  }
1550
2186
  }
2187
+ let labelColumn;
2188
+ let numericCells;
2189
+ let keyColumnsPresent = false;
2190
+ let keyColumnsFilled = false;
1551
2191
  for (const name of /* @__PURE__ */ new Set([...columns.keys(), ...Object.keys(row)])) {
1552
2192
  let accumulator = columns.get(name);
1553
2193
  if (!accumulator) {
@@ -1560,12 +2200,27 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1560
2200
  uniqueOverflow: false,
1561
2201
  timeParseCount: 0,
1562
2202
  timeFormatCounts: /* @__PURE__ */ new Map(),
2203
+ uuidValidCount: 0,
2204
+ ipValidCount: 0,
2205
+ lanIpCount: 0,
1563
2206
  samples: [],
1564
- sampleSet: /* @__PURE__ */ new Set()
2207
+ sampleSet: /* @__PURE__ */ new Set(),
2208
+ valueCounts: /* @__PURE__ */ new Map(),
2209
+ valueCountsOverflow: false,
2210
+ numericCount: 0,
2211
+ numericSum: 0,
2212
+ numericMin: Number.POSITIVE_INFINITY,
2213
+ numericMax: Number.NEGATIVE_INFINITY,
2214
+ numericSamples: [],
2215
+ keyLike: matchesName(name, ACCOUNT_NAMES) || matchesName(name, DISTINCT_NAMES) || matchesName(name, TIME_NAMES)
1565
2216
  };
1566
2217
  columns.set(name, accumulator);
1567
2218
  }
1568
2219
  const value = row[name];
2220
+ if (accumulator.keyLike) {
2221
+ keyColumnsPresent = true;
2222
+ if (!isMissing2(value)) keyColumnsFilled = true;
2223
+ }
1569
2224
  if (isMissing2(value)) {
1570
2225
  accumulator.missing += 1;
1571
2226
  continue;
@@ -1581,25 +2236,38 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1581
2236
  } else {
1582
2237
  accumulator.uniqueOverflow = true;
1583
2238
  }
1584
- if (options.collectSamples) recordSample(accumulator, value);
1585
- if (collectDelimitedTree && typeof value === "string" && value.trim().startsWith("{")) {
1586
- try {
1587
- const parsed = JSON.parse(value);
1588
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
1589
- let sampler = delimitedNested.get(name);
1590
- if (!sampler) {
1591
- sampler = { seen: 0, objects: [] };
1592
- delimitedNested.set(name, sampler);
1593
- }
1594
- sampler.seen += 1;
1595
- if (sampler.objects.length < NESTED_TREE_SAMPLE_LIMIT) {
1596
- sampler.objects.push(parsed);
1597
- } else {
1598
- const slot = randomInt(sampler.seen);
1599
- if (slot < NESTED_TREE_SAMPLE_LIMIT) sampler.objects[slot] = parsed;
2239
+ if (options.collectSamples) {
2240
+ recordSample(accumulator, value);
2241
+ recordValueFrequency(accumulator, value);
2242
+ }
2243
+ if (type === "number") {
2244
+ const numeric = Number(value);
2245
+ recordNumeric(accumulator, numeric, options.collectSamples === true);
2246
+ if (Number.isFinite(numeric)) (numericCells ??= []).push([name, numeric]);
2247
+ } else if (labelColumn === void 0 && matchesSummaryLabel(value)) {
2248
+ labelColumn = name;
2249
+ }
2250
+ if (collectDelimitedTree && typeof value === "string") {
2251
+ const trimmed = value.trim();
2252
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
2253
+ try {
2254
+ const parsed = JSON.parse(value);
2255
+ if (parsed !== null && typeof parsed === "object") {
2256
+ let sampler = delimitedNested.get(name);
2257
+ if (!sampler) {
2258
+ sampler = { seen: 0, values: [] };
2259
+ delimitedNested.set(name, sampler);
2260
+ }
2261
+ sampler.seen += 1;
2262
+ if (sampler.values.length < NESTED_TREE_SAMPLE_LIMIT) {
2263
+ sampler.values.push(parsed);
2264
+ } else {
2265
+ const slot = randomInt(sampler.seen);
2266
+ if (slot < NESTED_TREE_SAMPLE_LIMIT) sampler.values[slot] = parsed;
2267
+ }
1600
2268
  }
2269
+ } catch {
1601
2270
  }
1602
- } catch {
1603
2271
  }
1604
2272
  }
1605
2273
  if (isParseableTime(value, matchesName(name, TIME_NAMES))) {
@@ -1611,11 +2279,29 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1611
2279
  }
1612
2280
  }
1613
2281
  }
2282
+ if (matchesName(name, UUID_NAMES) && isValidUuid(value)) accumulator.uuidValidCount += 1;
2283
+ if (matchesName(name, IP_NAMES) && isValidIp(value)) {
2284
+ accumulator.ipValidCount += 1;
2285
+ if (isPrivateIp(value)) accumulator.lanIpCount += 1;
2286
+ }
1614
2287
  if (matchesName(name, TYPE_NAMES)) {
1615
2288
  const normalized = normalizeRecordType(value);
1616
2289
  if (normalized) recognizedRecordTypes.add(normalized);
1617
2290
  }
1618
2291
  }
2292
+ if (labelColumn !== void 0 || keyColumnsPresent && !keyColumnsFilled && numericCells !== void 0) {
2293
+ if (summaryCandidates.length < SUMMARY_ROW_CANDIDATE_LIMIT) {
2294
+ summaryCandidates.push({ row: rowNumber, labelColumn, numericCells: numericCells ?? [] });
2295
+ } else {
2296
+ summaryCandidatesTruncated = true;
2297
+ }
2298
+ }
2299
+ if (!duplicateKeysResolved) {
2300
+ duplicateKeysResolved = true;
2301
+ const keyColumns = resolveDuplicateKeyColumns(Object.keys(row), options.duplicateKeyFields);
2302
+ if (keyColumns) duplicateKeys = createDuplicateKeyTracker(keyColumns);
2303
+ }
2304
+ if (duplicateKeys) recordDuplicateKey(duplicateKeys, row, rowNumber);
1619
2305
  },
1620
2306
  {
1621
2307
  delimiter: options.delimiter,
@@ -1623,23 +2309,31 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1623
2309
  headerNames: options.headerNames,
1624
2310
  noHeader: options.noHeader,
1625
2311
  flattenRules: options.flattenRules,
1626
- mergeSheets: options.mergeSheets
2312
+ mergeSheets: options.mergeSheets,
2313
+ excelDateColumns,
2314
+ cellIssues,
2315
+ warnRagged: options.warnRagged,
2316
+ skipRows: options.skipRows,
2317
+ xlsxStructure,
2318
+ fillMergedCells: options.fillMergedCells,
2319
+ excludeHiddenRows: options.excludeHiddenRows
1627
2320
  }
1628
2321
  );
1629
2322
  const delimitedNestedTree = /* @__PURE__ */ new Map();
1630
2323
  for (const [columnName, sampler] of delimitedNested) {
1631
- if (sampler.objects.length > 0) delimitedNestedTree.set(columnName, buildNestedTree(sampler.objects));
2324
+ if (sampler.values.length > 0) delimitedNestedTree.set(columnName, buildColumnNestedTree(sampler.values));
1632
2325
  }
1633
2326
  const columnProfiles = [];
1634
2327
  const timeFormatByColumn = /* @__PURE__ */ new Map();
1635
2328
  for (const column of columns.values()) {
1636
2329
  const profile = formatColumnProfile(column, rowCount, options.collectSamples ?? false);
1637
- const nestedTree = delimitedNestedTree.get(column.name);
1638
- if (nestedTree && nestedTree.length > 0) profile.nested_tree = nestedTree;
2330
+ const nestedTree2 = delimitedNestedTree.get(column.name);
2331
+ if (nestedTree2 && nestedTree2.length > 0) profile.nested_tree = nestedTree2;
1639
2332
  columnProfiles.push(profile);
1640
2333
  const dominantFormat = dominantTimeFormat(column);
1641
2334
  if (dominantFormat) timeFormatByColumn.set(column.name, dominantFormat);
1642
2335
  }
2336
+ const nestedTree = collectNestedTree && nestedObjects.length > 0 ? buildNestedTree(nestedObjects) : void 0;
1643
2337
  const identityCandidates = findIdentityCandidates(columnProfiles);
1644
2338
  const recommendedMapping = recommendMapping({
1645
2339
  input,
@@ -1649,9 +2343,34 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1649
2343
  recognizedRecordTypes,
1650
2344
  sourceTimezone,
1651
2345
  headerNames: options.headerNames,
1652
- timeFormatByColumn
2346
+ skipRows: options.skipRows,
2347
+ fillMergedCells: options.fillMergedCells,
2348
+ excludeHiddenRows: options.excludeHiddenRows,
2349
+ timeFormatByColumn,
2350
+ nestedTree
1653
2351
  });
1654
2352
  const warnings = [...recommendedMapping.warnings ?? []];
2353
+ for (const column of columns.values()) {
2354
+ if (matchesName(column.name, UUID_NAMES) && column.nonMissing > 0) {
2355
+ const invalid = column.nonMissing - column.uuidValidCount;
2356
+ if (invalid > 0) warnings.push(`${column.name}: ${invalid} non-empty value(s) are not standard 36-character UUIDs; #uuid requires the standard UUID format.`);
2357
+ }
2358
+ if (matchesName(column.name, IP_NAMES) && column.nonMissing > 0) {
2359
+ const invalid = column.nonMissing - column.ipValidCount;
2360
+ if (invalid > 0) warnings.push(`${column.name}: ${invalid} non-empty value(s) are not valid IPv4 or IPv6 addresses.`);
2361
+ if (column.lanIpCount > 0) warnings.push(`${column.name}: ${column.lanIpCount} value(s) are private/LAN IP addresses; AE cannot resolve geo information for them.`);
2362
+ }
2363
+ }
2364
+ if (excelDateColumns.size > 0) {
2365
+ warnings.push(`${[...excelDateColumns].join(", ")}: read as date/time value(s) from the Excel number format rather than as Excel serial numbers. If these columns were uploaded to AE before, they may have been received as number properties, whose type is now locked; confirm with the user before mapping them.`);
2366
+ }
2367
+ warnings.push(...cellIssueWarnings(cellIssues));
2368
+ if (xlsxStructure) warnings.push(...xlsxStructureWarnings(xlsxStructure));
2369
+ const structureReport = xlsxStructure ? xlsxStructureReport(xlsxStructure) : void 0;
2370
+ const summaryRows = confirmSummaryRows(summaryCandidates, columns);
2371
+ if (summaryRows.length > 0) warnings.push(summaryRowWarning(summaryRows, summaryCandidatesTruncated));
2372
+ const duplicateKeyReport = duplicateKeys ? buildDuplicateKeyReport(duplicateKeys) : void 0;
2373
+ if (duplicateKeyReport) warnings.push(duplicateKeyWarning(duplicateKeyReport));
1655
2374
  if (rowCount === 0) warnings.push("The selected data set contains no data rows.");
1656
2375
  return {
1657
2376
  version: "ae-local-data-profile/v1",
@@ -1670,7 +2389,10 @@ async function profileLocalData(input, dataSet, sourceTimezone = "Asia/Shanghai"
1670
2389
  (recommendedMapping.account_id_field || recommendedMapping.distinct_id_field) && recommendedMapping.time.field
1671
2390
  ),
1672
2391
  warnings,
1673
- ...collectNestedTree && nestedObjects.length > 0 ? { nested_tree: buildNestedTree(nestedObjects) } : {}
2392
+ ...nestedTree ? { nested_tree: nestedTree } : {},
2393
+ ...structureReport ? { xlsx_structure: structureReport } : {},
2394
+ ...summaryRows.length > 0 ? { summary_rows: summaryRows } : {},
2395
+ ...duplicateKeyReport ? { duplicate_keys: duplicateKeyReport } : {}
1674
2396
  };
1675
2397
  }
1676
2398
  function normalizeAeName(input, fallback) {
@@ -1743,24 +2465,12 @@ function recommendMapping(input) {
1743
2465
  warnings.push("No event-name column was found; review the generated default event name.");
1744
2466
  }
1745
2467
  const reserved = new Set([account?.name, distinct?.name, time?.name, event?.name, recordType?.name].filter(Boolean));
1746
- const usedTargets = /* @__PURE__ */ new Set();
1747
- const properties = input.columns.filter((column) => !reserved.has(column.name)).map((column, index) => {
1748
- const baseTarget = normalizeAeName(column.name, `field_${index + 1}`);
1749
- let target = baseTarget;
1750
- let suffix = 2;
1751
- while (usedTargets.has(target)) target = `${baseTarget.slice(0, 46)}_${suffix++}`;
1752
- usedTargets.add(target);
1753
- const type = mappingType(column.inferred_type);
1754
- return {
1755
- source: column.name,
1756
- target,
1757
- type,
1758
- ...type === "object" || type === "list" ? { transform: "json" } : {}
1759
- };
1760
- });
2468
+ const recordRoots = /* @__PURE__ */ new Map();
2469
+ for (const node of input.nestedTree ?? []) recordRoots.set(node.name, node);
2470
+ const { properties, flattenRules } = recommendProperties(input.columns, reserved, recordRoots, warnings);
1761
2471
  const defaultEventSource = input.dataSet.kind === "sheet" ? input.dataSet.label : basename2(input.input.filePath, extname2(input.input.filePath));
1762
2472
  return {
1763
- version: "ae-local-data-mapping/v1",
2473
+ version: MAPPING_VERSION,
1764
2474
  source: {
1765
2475
  sha256: input.input.sha256,
1766
2476
  format: input.input.format,
@@ -1777,9 +2487,13 @@ function recommendMapping(input) {
1777
2487
  },
1778
2488
  ...time ? { time_format: input.timeFormatByColumn.get(time.name) } : {},
1779
2489
  ...input.headerNames && input.headerNames.length > 0 ? { headers: input.headerNames } : {},
2490
+ ...input.skipRows ? { skip_rows: input.skipRows } : {},
2491
+ ...input.fillMergedCells ? { fill_merged_cells: true } : {},
2492
+ ...input.excludeHiddenRows ? { exclude_hidden_rows: true } : {},
1780
2493
  ...recordType ? { record_type_field: recordType.name } : {},
1781
2494
  ...event ? { event_name_field: event.name } : {},
1782
2495
  ...mode !== "user_set" && !event ? { default_event_name: normalizeAeName(defaultEventSource, "local_event") } : {},
2496
+ ...Object.keys(flattenRules).length > 0 ? { flatten_rules: flattenRules } : {},
1783
2497
  properties,
1784
2498
  ...warnings.length > 0 ? { warnings } : {}
1785
2499
  };
@@ -1788,7 +2502,7 @@ function formatColumnProfile(column, rowCount, includeSamples) {
1788
2502
  const nonNullTypes = [...column.types.entries()].filter(([type]) => type !== "null").sort((left, right) => right[1] - left[1]);
1789
2503
  const inferred = nonNullTypes.length === 0 ? "unknown" : nonNullTypes.length === 1 ? nonNullTypes[0][0] : compatibleTypes(nonNullTypes.map(([type]) => type)) ? nonNullTypes[0][0] : "mixed";
1790
2504
  const finalType = inferred === "number" && idLikeColumn(column.name) ? "string" : inferred;
1791
- return {
2505
+ const profile = {
1792
2506
  name: column.name,
1793
2507
  inferred_type: finalType,
1794
2508
  missing_count: column.missing,
@@ -1800,6 +2514,15 @@ function formatColumnProfile(column, rowCount, includeSamples) {
1800
2514
  time_parse_ratio: ratio(column.timeParseCount, column.nonMissing),
1801
2515
  ...includeSamples ? { samples: column.samples } : {}
1802
2516
  };
2517
+ if (includeSamples) {
2518
+ const frequency = formatValueFrequency(column);
2519
+ if (frequency) profile.value_frequency = frequency;
2520
+ if (finalType === "number") {
2521
+ const summary = formatNumericSummary(column);
2522
+ if (summary) profile.numeric_summary = summary;
2523
+ }
2524
+ }
2525
+ return profile;
1803
2526
  }
1804
2527
  function findCandidate(columns, names) {
1805
2528
  return columns.find((column) => matchesName(column.name, names));
@@ -1863,9 +2586,120 @@ function normalizeRecordType(value) {
1863
2586
  };
1864
2587
  return aliases[normalized];
1865
2588
  }
2589
+ function recommendProperties(columns, reserved, recordRoots, warnings) {
2590
+ const properties = [];
2591
+ const flattenRules = {};
2592
+ const usedTargets = /* @__PURE__ */ new Set();
2593
+ const claim = (desired) => {
2594
+ let target = desired;
2595
+ let suffix = 2;
2596
+ while (usedTargets.has(target)) target = `${desired.slice(0, 46)}_${suffix++}`;
2597
+ usedTargets.add(target);
2598
+ return target;
2599
+ };
2600
+ const state = { properties, flattenRules, usedTargets, warnings, claim };
2601
+ columns.filter((column) => !reserved.has(column.name)).forEach((column, index) => {
2602
+ const baseName = normalizeAeName(column.name, `field_${index + 1}`);
2603
+ const valueNode = columnValueNode(column, recordRoots);
2604
+ if (!valueNode) {
2605
+ const type = mappingType(column.inferred_type);
2606
+ const target = claim(baseName);
2607
+ properties.push({ source: column.name, target, type, ...isContainerType(type) ? { transform: "json" } : {} });
2608
+ return;
2609
+ }
2610
+ if (valueNode.kind === "primitive") {
2611
+ const type = mappingType(valueNode.inferredType ?? column.inferred_type);
2612
+ const target = claim(baseName);
2613
+ properties.push({ source: column.name, target, type });
2614
+ return;
2615
+ }
2616
+ if (valueNode.kind === "object") {
2617
+ recommendObject(state, baseName, column.name, valueNode.children ?? [], column.name);
2618
+ return;
2619
+ }
2620
+ recommendArray(state, baseName, column.name, valueNode, column.name);
2621
+ });
2622
+ return { properties, flattenRules };
2623
+ }
2624
+ function columnValueNode(column, recordRoots) {
2625
+ const recordNode = recordRoots.get(column.name);
2626
+ if (recordNode) return recordNode;
2627
+ const tree = column.nested_tree;
2628
+ if (!tree || tree.length === 0) return void 0;
2629
+ if (tree.length === 1 && tree[0].kind === "array") return tree[0];
2630
+ return { path: "", name: column.name, kind: "object", children: tree, nonEmpty: true };
2631
+ }
2632
+ function isScalarNode(node) {
2633
+ return node.kind === "primitive" || node.kind === "array" && node.elementKind === "primitive";
2634
+ }
2635
+ function isContainerType(type) {
2636
+ return type === "object" || type === "list" || type === "array_row";
2637
+ }
2638
+ function scalarPropType(node) {
2639
+ if (node.kind === "array") return "list";
2640
+ switch (node.inferredType) {
2641
+ case "number":
2642
+ return "number";
2643
+ case "boolean":
2644
+ return "boolean";
2645
+ case "datetime":
2646
+ return "datetime";
2647
+ default:
2648
+ return "string";
2649
+ }
2650
+ }
2651
+ function snakeSegment(name) {
2652
+ return normalizeAeName(name, "field");
2653
+ }
2654
+ function recommendObject(state, prefix, dotPath, children, columnName) {
2655
+ const hasComposite = children.some((child) => child.kind === "object" || child.kind === "array" && child.elementKind === "object");
2656
+ if (!hasComposite) {
2657
+ const parentTarget = state.claim(prefix);
2658
+ const source = dotPath === columnName ? columnName : parentTarget;
2659
+ if (source !== columnName) state.flattenRules[parentTarget] = dotPath;
2660
+ state.properties.push({ source, target: parentTarget, type: "object", transform: "json" });
2661
+ for (const child of children) {
2662
+ state.properties.push({ source: parentTarget, target: `${parentTarget}.${snakeSegment(child.name)}`, type: scalarPropType(child) });
2663
+ }
2664
+ return;
2665
+ }
2666
+ for (const child of children) {
2667
+ const childPrefix = `${prefix}_${snakeSegment(child.name)}`;
2668
+ const childDotPath = `${dotPath}.${child.name}`;
2669
+ if (isScalarNode(child)) {
2670
+ const target = state.claim(childPrefix);
2671
+ state.flattenRules[target] = childDotPath;
2672
+ state.properties.push({ source: target, target, type: scalarPropType(child) });
2673
+ } else if (child.kind === "object") {
2674
+ recommendObject(state, childPrefix, childDotPath, child.children ?? [], columnName);
2675
+ } else {
2676
+ recommendArray(state, childPrefix, childDotPath, child, columnName);
2677
+ }
2678
+ }
2679
+ }
2680
+ function recommendArray(state, prefix, dotPath, node, columnName) {
2681
+ if (node.elementKind !== "object") {
2682
+ const target = state.claim(prefix);
2683
+ const source2 = dotPath === columnName ? columnName : target;
2684
+ if (source2 !== columnName) state.flattenRules[target] = dotPath;
2685
+ state.properties.push({ source: source2, target, type: "list", transform: "json" });
2686
+ return;
2687
+ }
2688
+ const parentTarget = state.claim(prefix);
2689
+ const source = dotPath === columnName ? columnName : parentTarget;
2690
+ if (source !== columnName) state.flattenRules[parentTarget] = dotPath;
2691
+ state.properties.push({ source, target: parentTarget, type: "array_row", transform: "json" });
2692
+ for (const field of node.children ?? []) {
2693
+ if (isScalarNode(field)) {
2694
+ state.properties.push({ source: parentTarget, target: `${parentTarget}.${snakeSegment(field.name)}`, type: scalarPropType(field) });
2695
+ } else {
2696
+ state.warnings.push(`${dotPath} element field "${field.name}" is nested; it stays inside the ${parentTarget} array data and is not declared as a sub-property (array-element flattening is not yet supported).`);
2697
+ }
2698
+ }
2699
+ }
1866
2700
  function mappingType(type) {
1867
2701
  if (type === "datetime") return "datetime";
1868
- if (type === "number" || type === "boolean" || type === "list" || type === "object") return type;
2702
+ if (type === "number" || type === "boolean" || type === "object" || type === "list") return type;
1869
2703
  return "string";
1870
2704
  }
1871
2705
  function compatibleTypes(types) {
@@ -1903,6 +2737,176 @@ function recordSample(accumulator, value) {
1903
2737
  accumulator.sampleSet.add(text);
1904
2738
  accumulator.samples.push(text);
1905
2739
  }
2740
+ function recordValueFrequency(accumulator, value) {
2741
+ if (accumulator.valueCountsOverflow) return;
2742
+ const text = truncateSample2(value);
2743
+ const seen = accumulator.valueCounts.get(text);
2744
+ if (seen !== void 0) {
2745
+ accumulator.valueCounts.set(text, seen + 1);
2746
+ return;
2747
+ }
2748
+ if (accumulator.valueCounts.size >= VALUE_FREQUENCY_LIMIT) {
2749
+ accumulator.valueCountsOverflow = true;
2750
+ accumulator.valueCounts.clear();
2751
+ return;
2752
+ }
2753
+ accumulator.valueCounts.set(text, 1);
2754
+ }
2755
+ function recordNumeric(accumulator, numeric, retainForQuantiles) {
2756
+ if (!Number.isFinite(numeric)) return;
2757
+ accumulator.numericCount += 1;
2758
+ accumulator.numericSum += numeric;
2759
+ if (numeric < accumulator.numericMin) accumulator.numericMin = numeric;
2760
+ if (numeric > accumulator.numericMax) accumulator.numericMax = numeric;
2761
+ if (!retainForQuantiles) return;
2762
+ if (accumulator.numericSamples.length < NUMERIC_SAMPLE_LIMIT) {
2763
+ accumulator.numericSamples.push(numeric);
2764
+ return;
2765
+ }
2766
+ const slot = randomInt(accumulator.numericCount);
2767
+ if (slot < NUMERIC_SAMPLE_LIMIT) accumulator.numericSamples[slot] = numeric;
2768
+ }
2769
+ function formatValueFrequency(accumulator) {
2770
+ if (accumulator.valueCountsOverflow || accumulator.valueCounts.size === 0) return void 0;
2771
+ const ordered = [...accumulator.valueCounts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
2772
+ if (ordered[0][1] < 2) return void 0;
2773
+ return ordered.slice(0, VALUE_FREQUENCY_TOP).map(([value, count]) => ({ value, count, ratio: ratio(count, accumulator.nonMissing) }));
2774
+ }
2775
+ function formatNumericSummary(accumulator) {
2776
+ if (accumulator.numericCount === 0 || accumulator.numericSamples.length === 0) return void 0;
2777
+ const sorted = [...accumulator.numericSamples].sort((left, right) => left - right);
2778
+ return {
2779
+ count: accumulator.numericCount,
2780
+ min: round(accumulator.numericMin),
2781
+ max: round(accumulator.numericMax),
2782
+ sum: round(accumulator.numericSum),
2783
+ mean: round(accumulator.numericSum / accumulator.numericCount),
2784
+ p25: round(quantile(sorted, 0.25)),
2785
+ median: round(quantile(sorted, 0.5)),
2786
+ p75: round(quantile(sorted, 0.75)),
2787
+ quantiles_approximate: accumulator.numericCount > accumulator.numericSamples.length
2788
+ };
2789
+ }
2790
+ function quantile(sorted, fraction) {
2791
+ const position = (sorted.length - 1) * fraction;
2792
+ const lower = Math.floor(position);
2793
+ const upper = Math.ceil(position);
2794
+ if (lower === upper) return sorted[lower];
2795
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
2796
+ }
2797
+ function round(value) {
2798
+ return Number.isFinite(value) ? Number(value.toFixed(6)) : value;
2799
+ }
2800
+ function matchesSummaryLabel(value) {
2801
+ if (typeof value !== "string") return false;
2802
+ const text = value.trim().replace(/[\s::、,,.。]+$/u, "");
2803
+ if (text.length === 0 || text.length > SUMMARY_LABEL_MAX_LENGTH) return false;
2804
+ if (SUMMARY_LABELS_CJK.some((label) => text.startsWith(label))) return true;
2805
+ return SUMMARY_LABELS_LATIN.has(text.toLowerCase());
2806
+ }
2807
+ function confirmSummaryRows(candidates, columns) {
2808
+ const confirmed = [];
2809
+ for (const candidate of candidates) {
2810
+ const totalColumns = [];
2811
+ for (const [name, value] of candidate.numericCells) {
2812
+ const accumulator = columns.get(name);
2813
+ if (!accumulator || accumulator.numericCount < 3 || value === 0) continue;
2814
+ const total = accumulator.numericSum;
2815
+ if (Math.abs(value * 2 - total) <= Math.max(1e-6, Math.abs(total) * 1e-9)) totalColumns.push(name);
2816
+ }
2817
+ const signals = [];
2818
+ if (candidate.labelColumn !== void 0) signals.push("total_label");
2819
+ if (totalColumns.length > 0) signals.push("column_total");
2820
+ if (signals.length === 0) continue;
2821
+ confirmed.push({
2822
+ row: candidate.row,
2823
+ signals,
2824
+ ...candidate.labelColumn !== void 0 ? { label_column: candidate.labelColumn } : {},
2825
+ ...totalColumns.length > 0 ? { total_columns: totalColumns } : {}
2826
+ });
2827
+ }
2828
+ return confirmed;
2829
+ }
2830
+ function summaryRowWarning(rows, truncated) {
2831
+ const shown = rows.slice(0, SUMMARY_ROW_WARNING_LIMIT).map((entry) => entry.row);
2832
+ const remaining = rows.length - shown.length;
2833
+ const labelled = rows.filter((entry) => entry.signals.includes("total_label")).length;
2834
+ const totals = rows.filter((entry) => entry.signals.includes("column_total")).length;
2835
+ const reasons = [
2836
+ labelled > 0 ? `${labelled} carry a total-like label (\u5408\u8BA1 / \u603B\u8BA1 / \u5C0F\u8BA1 / \u6C47\u603B / Total / Subtotal)` : void 0,
2837
+ totals > 0 ? `${totals} hold a number equal to the total of its column's other rows` : void 0
2838
+ ].filter((reason) => reason !== void 0).join("; ");
2839
+ const scope = truncated ? ` The scan stopped after ${SUMMARY_ROW_CANDIDATE_LIMIT} candidate rows, so there may be more.` : "";
2840
+ return `${rows.length} row(s) read as a summary line rather than an observation: ${reasons}. Rows ${shown.join(", ")}${remaining > 0 ? ` and ${remaining} more` : ""} (data-row ordinals, the numbering invalid.rows.jsonl and --salvage-from use; see summary_rows).${scope} Nothing was removed and nothing changed: convert writes these rows as records, and every number reported for their columns counts them, so a real total row makes that column's sum twice its actual total and its max the total. There is no flag that drops a data row; when the user confirms a row is a total, remove it from the source file or re-export without it, then inspect again.`;
2841
+ }
2842
+ function resolveDuplicateKeyColumns(names, explicit) {
2843
+ if (explicit && explicit.length > 0) {
2844
+ const unique = [...new Set(explicit.filter((name) => name.length > 0))];
2845
+ return unique.length > 1 ? unique : void 0;
2846
+ }
2847
+ const identity = names.find((name) => matchesName(name, ACCOUNT_NAMES)) ?? names.find((name) => matchesName(name, DISTINCT_NAMES));
2848
+ const time = names.find((name) => matchesName(name, TIME_NAMES));
2849
+ if (!identity || !time) return void 0;
2850
+ const event = names.find((name) => matchesName(name, EVENT_NAMES));
2851
+ return event ? [identity, time, event] : [identity, time];
2852
+ }
2853
+ function createDuplicateKeyTracker(columns) {
2854
+ return { columns, firstRow: /* @__PURE__ */ new Map(), groups: /* @__PURE__ */ new Map(), checkedRows: 0, overflow: false };
2855
+ }
2856
+ function recordDuplicateKey(tracker, row, rowNumber) {
2857
+ const parts = [];
2858
+ for (const name of tracker.columns) {
2859
+ const value = row[name];
2860
+ if (isMissing2(value)) return;
2861
+ parts.push(sampleText(value).trim());
2862
+ }
2863
+ tracker.checkedRows += 1;
2864
+ const hash = createHash2("sha256").update(JSON.stringify(parts)).digest("hex");
2865
+ const group = tracker.groups.get(hash);
2866
+ if (group) {
2867
+ group.count += 1;
2868
+ if (group.rows.length < DUPLICATE_GROUP_ROW_LIMIT) group.rows.push(rowNumber);
2869
+ else group.rowsTruncated = true;
2870
+ return;
2871
+ }
2872
+ const firstRow = tracker.firstRow.get(hash);
2873
+ if (firstRow !== void 0) {
2874
+ tracker.groups.set(hash, { count: 2, rows: [firstRow, rowNumber], rowsTruncated: false });
2875
+ return;
2876
+ }
2877
+ if (tracker.firstRow.size >= DUPLICATE_KEY_TRACK_LIMIT) {
2878
+ tracker.overflow = true;
2879
+ return;
2880
+ }
2881
+ tracker.firstRow.set(hash, rowNumber);
2882
+ }
2883
+ function buildDuplicateKeyReport(tracker) {
2884
+ if (tracker.groups.size === 0) return void 0;
2885
+ const ordered = [...tracker.groups.entries()].sort(([, left], [, right]) => right.count - left.count || left.rows[0] - right.rows[0]);
2886
+ let extraRows = 0;
2887
+ for (const [, group] of ordered) extraRows += group.count - 1;
2888
+ const groups = ordered.slice(0, DUPLICATE_GROUP_LIMIT).map(([hash, group]) => ({
2889
+ key_hash: hash.slice(0, 16),
2890
+ count: group.count,
2891
+ rows: group.rows,
2892
+ ...group.rowsTruncated ? { rows_truncated: true } : {}
2893
+ }));
2894
+ return {
2895
+ key_columns: tracker.columns,
2896
+ checked_rows: tracker.checkedRows,
2897
+ duplicate_groups: ordered.length,
2898
+ extra_rows: extraRows,
2899
+ groups,
2900
+ ...ordered.length > DUPLICATE_GROUP_LIMIT ? { groups_truncated: true } : {},
2901
+ ...tracker.overflow ? { tracking_truncated: true } : {}
2902
+ };
2903
+ }
2904
+ function duplicateKeyWarning(report) {
2905
+ const examples = report.groups.slice(0, DUPLICATE_WARNING_GROUP_LIMIT).map((group) => `rows ${group.rows.join(", ")}${group.rows_truncated ? ", \u2026" : ""} (${group.count}\xD7)`).join("; ");
2906
+ const remaining = report.duplicate_groups - Math.min(report.groups.length, DUPLICATE_WARNING_GROUP_LIMIT);
2907
+ const scope = report.tracking_truncated ? ` Tracking stopped after ${DUPLICATE_KEY_TRACK_LIMIT} distinct keys, so there may be more.` : "";
2908
+ return `${report.duplicate_groups} business key(s) appear on more than one row, ${report.extra_rows} extra row(s) in total, over ${report.checked_rows} row(s) that carried a value in every key column. Key: ${report.key_columns.join(" + ")}. Examples: ${examples}${remaining > 0 ? ` and ${remaining} more group(s)` : ""} (data-row ordinals, the numbering invalid.rows.jsonl and --salvage-from use; see duplicate_keys).${scope} Nothing was removed: repeated rows are sometimes real (one order line per product), and AE appends every accepted event with no way to un-send it, so this has to be settled before upload. Ask the user whether the repeats are separate observations; if they are not, remove them from the source file and inspect again. Values are compared as written, so two spellings of the same instant are two different keys, and a source with a unique key of its own (an order id) is not compared on it unless the mapping names it.`;
2909
+ }
1906
2910
  function truncateSample2(value) {
1907
2911
  const text = sampleText(value);
1908
2912
  if (text.length <= SAMPLE_TRUNCATE_LENGTH) return text;
@@ -1928,7 +2932,7 @@ function dominantTimeFormat(column) {
1928
2932
  return best && bestCount >= total * 0.9 ? best : void 0;
1929
2933
  }
1930
2934
 
1931
- // src/commands/data-integration/local-data/inspect.ts
2935
+ // src/commands/data-integration/inspect.ts
1932
2936
  var HEADERLESS_WARNING = "The first row appears to be data, not a header; columns were auto-named col_1..col_N. Re-run with --headers to supply explicit names.";
1933
2937
  var dataIntegrationInspect = {
1934
2938
  service: "data-integration",
@@ -1940,9 +2944,21 @@ var dataIntegrationInspect = {
1940
2944
  { name: "data-set", type: "string", sensitive: true, desc: "Sheet or JSON Path ID returned by a discovery-only inspection." },
1941
2945
  { name: "source-timezone", type: "string", default: "Asia/Shanghai", desc: "IANA timezone used to interpret source times without an offset." },
1942
2946
  { name: "headers", type: "string", sensitive: true, desc: "Comma-separated explicit column names for a headerless file; the first row is data." },
1943
- { name: "headerless", type: "boolean", default: false, desc: "Treat the first row as data and auto-generate col_1..col_N names." }
2947
+ { name: "headerless", type: "boolean", default: false, desc: "Treat the first row as data and auto-generate col_1..col_N names." },
2948
+ { name: "skip-rows", type: "number", default: 0, desc: "Discard the first N rows before the header row is read, for exports with a title or banner above the header. Delimited and Excel only." },
2949
+ { name: "fill-merged-cells", type: "boolean", default: false, desc: "Copy each merged block value into the cells its own range covers, so rows below the first row of a merged block keep the value Excel displays. XLSX only; off by default because those cells are empty in the file." },
2950
+ { name: "exclude-hidden-rows", type: "boolean", default: false, desc: "Leave rows hidden in the source worksheet out of the profile. XLSX only; off by default because a hidden row may still be real data." }
1944
2951
  ],
1945
2952
  risk: "read",
2953
+ validate: (ctx) => {
2954
+ const skipRows = ctx.num("skip-rows");
2955
+ if (!Number.isInteger(skipRows) || skipRows < 0) {
2956
+ throw new CliValidationError("Skipped rows must be a non-negative integer.", {
2957
+ code: "LOCAL_DATA_SKIP_ROWS_INVALID",
2958
+ location: { field: "skip-rows" }
2959
+ });
2960
+ }
2961
+ },
1946
2962
  // Fast size/time pre-check (stat + format/encoding sniff only — no sha256, no profile).
1947
2963
  // Lets agents surface the estimate before committing to a multi-minute full inspection.
1948
2964
  dryRun: async (ctx) => {
@@ -1973,46 +2989,69 @@ var dataIntegrationInspect = {
1973
2989
  const noHeader = ctx.bool("headerless");
1974
2990
  const sourceTimezone = ctx.str("source-timezone");
1975
2991
  const requested = ctx.str("data-set").trim() || void 0;
2992
+ const skipRows = ctx.num("skip-rows");
2993
+ const fillMergedCells = ctx.bool("fill-merged-cells");
2994
+ const excludeHiddenRows = ctx.bool("exclude-hidden-rows");
1976
2995
  if (inputFiles.length === 1) {
1977
2996
  const input = await inspectLocalDataInput(inputFiles[0]);
1978
2997
  const headerConsistency = await readExcelHeaderConsistency(input);
2998
+ const excludedSheets = summarizeExcludedSheets(input);
1979
2999
  if (!requested && input.dataSets.length > 1) {
1980
3000
  return {
1981
3001
  version: "ae-local-data-profile/v1",
1982
3002
  selection_required: true,
1983
3003
  source: { format: input.format, size_bytes: input.sizeBytes, sha256: input.sha256 },
1984
3004
  data_sets: input.dataSets,
3005
+ ...excludedSheets,
1985
3006
  ...headerConsistency ?? {},
1986
3007
  next_step: "Run inspect again with --data-set, then review the recommended mapping."
1987
3008
  };
1988
3009
  }
1989
3010
  const dataSet = selectDataSet(input, requested);
1990
- const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input);
3011
+ const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input, skipRows);
3012
+ const headerSignal = headerNames || noHeader ? void 0 : await detectHeaderSignal(input, dataSet, skipRows);
1991
3013
  const profile = await profileLocalData(input, dataSet, sourceTimezone, {
1992
3014
  collectSamples: true,
1993
3015
  collectNestedTree: true,
1994
3016
  headerNames,
1995
- noHeader: noHeader || Boolean(headerPresence)
3017
+ noHeader: noHeader || Boolean(headerPresence),
3018
+ skipRows,
3019
+ fillMergedCells,
3020
+ excludeHiddenRows
1996
3021
  });
1997
3022
  const annotated = headerPresence ? annotateHeaderless(profile, headerPresence) : profile;
1998
- return headerConsistency ? { ...annotated, ...headerConsistency } : annotated;
3023
+ return {
3024
+ ...annotated,
3025
+ ...excludedSheets,
3026
+ ...headerConsistency ?? {},
3027
+ ...annotateRowSkips(annotated.warnings, skipRows, headerSignal)
3028
+ };
1999
3029
  }
2000
3030
  const files = [];
2001
3031
  for (const inputFile of inputFiles) {
2002
3032
  const input = await inspectLocalDataInput(inputFile);
2003
3033
  const dataSet = selectDataSet(input);
2004
- const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input);
3034
+ const headerPresence = headerNames || noHeader ? void 0 : detectHeaderPresence(input, skipRows);
3035
+ const headerSignal = headerNames || noHeader ? void 0 : await detectHeaderSignal(input, dataSet, skipRows);
2005
3036
  const profile = await profileLocalData(input, dataSet, sourceTimezone, {
2006
3037
  collectSamples: true,
2007
3038
  collectNestedTree: true,
2008
3039
  headerNames,
2009
- noHeader: noHeader || Boolean(headerPresence)
3040
+ noHeader: noHeader || Boolean(headerPresence),
3041
+ skipRows,
3042
+ fillMergedCells,
3043
+ excludeHiddenRows
2010
3044
  });
2011
3045
  const annotated = headerPresence ? annotateHeaderless(profile, headerPresence) : profile;
2012
3046
  const headerConsistency = await readExcelHeaderConsistency(input);
2013
3047
  files.push({
2014
3048
  file: basename3(inputFile),
2015
- profile: headerConsistency ? { ...annotated, ...headerConsistency } : annotated
3049
+ profile: {
3050
+ ...annotated,
3051
+ ...summarizeExcludedSheets(input),
3052
+ ...headerConsistency ?? {},
3053
+ ...annotateRowSkips(annotated.warnings, skipRows, headerSignal)
3054
+ }
2016
3055
  });
2017
3056
  }
2018
3057
  return {
@@ -2023,6 +3062,13 @@ var dataIntegrationInspect = {
2023
3062
  };
2024
3063
  }
2025
3064
  };
3065
+ function summarizeExcludedSheets(input) {
3066
+ const excluded = input.excludedDataSets ?? [];
3067
+ if (excluded.length === 0) return {};
3068
+ return {
3069
+ excluded_sheets: excluded.map((dataSet) => ({ name: dataSet.label, reason: "hidden", data_set: dataSet.id }))
3070
+ };
3071
+ }
2026
3072
  async function readExcelHeaderConsistency(input) {
2027
3073
  if (input.format !== "xls" && input.format !== "xlsx") return void 0;
2028
3074
  return summarizeHeaderConsistency(await readExcelSheetHeaders(input.filePath, input.format));
@@ -2033,10 +3079,11 @@ function summarizeHeaderConsistency(sheets) {
2033
3079
  const allSame = sheets.every((sheet) => JSON.stringify(sheet.headers) === first);
2034
3080
  return allSame ? { header_consistency: "all_same" } : { header_consistency: "different", header_details: sheets.map((sheet) => ({ name: sheet.name, headers: sheet.headers })) };
2035
3081
  }
2036
- function detectHeaderPresence(input) {
3082
+ function detectHeaderPresence(input, skipRows = 0) {
2037
3083
  if (input.format !== "csv" && input.format !== "tsv") return void 0;
2038
3084
  const delimiter = input.delimiter ?? (input.format === "tsv" ? " " : ",");
2039
- const records = peekDelimitedRecords(input.filePath, { delimiter, encoding: input.encoding, limit: 10 });
3085
+ const peeked = peekDelimitedRecords(input.filePath, { delimiter, encoding: input.encoding, limit: 10 + skipRows });
3086
+ const records = peeked.slice(skipRows);
2040
3087
  const detection = detectHeaderRow(records);
2041
3088
  if (detection.hasHeaders) return void 0;
2042
3089
  return {
@@ -2044,6 +3091,41 @@ function detectHeaderPresence(input) {
2044
3091
  autoHeaders: (records[0] ?? []).map((_, index) => `col_${index + 1}`)
2045
3092
  };
2046
3093
  }
3094
+ async function detectHeaderSignal(input, dataSet, skipRows) {
3095
+ const rows = await peekHeaderRows(input, dataSet, skipRows);
3096
+ if (!rows || rows.length === 0) return void 0;
3097
+ const titleRows = detectLeadingTitleRows(rows).map((title) => ({ ...title, row: title.row + skipRows }));
3098
+ if (input.format !== "xlsx") return titleRows.length > 0 ? { titleRows } : void 0;
3099
+ const detection = detectHeaderRow(rows.map((row) => row.map((value) => isEmptyCell(value) ? "" : String(value))));
3100
+ if (detection.hasHeaders && titleRows.length === 0) return void 0;
3101
+ return { ...detection.hasHeaders ? {} : { detection }, titleRows };
3102
+ }
3103
+ async function peekHeaderRows(input, dataSet, skipRows) {
3104
+ if (input.format === "csv" || input.format === "tsv") {
3105
+ const delimiter = input.delimiter ?? (input.format === "tsv" ? " " : ",");
3106
+ return peekDelimitedRecords(input.filePath, { delimiter, encoding: input.encoding, limit: 10 + skipRows }).slice(skipRows);
3107
+ }
3108
+ if (input.format === "xlsx" && dataSet.kind === "sheet") {
3109
+ return (await peekXlsxRows(input.filePath, dataSet.label, 10 + skipRows)).slice(skipRows);
3110
+ }
3111
+ return void 0;
3112
+ }
3113
+ function isEmptyCell(value) {
3114
+ return value === null || value === void 0 || value === "";
3115
+ }
3116
+ var TITLE_ROW_WARNING = "The first rows here hold a single value each, which looks like a title or banner above the real header row; the header was still read from the first row. See leading_title_rows and re-run with --skip-rows N if that is what they are.";
3117
+ var EXCEL_HEADERLESS_WARNING = "The first row of this worksheet looks like data rather than a header, but it was still used as the header. See header_signal, and re-run with --headers or --headerless if it is data.";
3118
+ function annotateRowSkips(warnings, skipRows, signal) {
3119
+ const extra = [];
3120
+ if (signal?.titleRows.length) extra.push(TITLE_ROW_WARNING);
3121
+ if (signal?.detection) extra.push(EXCEL_HEADERLESS_WARNING);
3122
+ return {
3123
+ warnings: extra.length > 0 ? [...warnings, ...extra] : warnings,
3124
+ ...skipRows > 0 ? { skipped_rows: skipRows } : {},
3125
+ ...signal?.titleRows.length ? { leading_title_rows: signal.titleRows } : {},
3126
+ ...signal?.detection ? { header_signal: signal.detection } : {}
3127
+ };
3128
+ }
2047
3129
  function annotateHeaderless(profile, presence) {
2048
3130
  return {
2049
3131
  ...profile,
@@ -2060,7 +3142,7 @@ function splitHeaders(raw) {
2060
3142
  return headers.length > 0 ? headers : void 0;
2061
3143
  }
2062
3144
 
2063
- // src/commands/data-integration/local-data/plan.ts
3145
+ // src/commands/data-integration/plan.ts
2064
3146
  import { writeFile } from "fs/promises";
2065
3147
  var VALID_LOCALES = /* @__PURE__ */ new Set(["zh", "en", "ja", "ko"]);
2066
3148
  var EVENT_NAME_RE = /^[a-z][a-z0-9_]*$/;
@@ -2076,6 +3158,8 @@ function toPropType(type) {
2076
3158
  return "array_string";
2077
3159
  case "object":
2078
3160
  return "object";
3161
+ case "array_row":
3162
+ return "array_row";
2079
3163
  case "string":
2080
3164
  return "string";
2081
3165
  }
@@ -2083,13 +3167,16 @@ function toPropType(type) {
2083
3167
  function buildDraftFromMapping(options) {
2084
3168
  const { mapping } = options;
2085
3169
  const excluded = new Set(mapping.exclude_columns ?? []);
2086
- const properties = mapping.properties.filter((property) => !excluded.has(property.source)).map((property) => ({
2087
- name: property.target,
2088
- display_name: property.source,
2089
- desc: property.desc ?? property.source,
2090
- type: toPropType(property.type),
2091
- source: "data"
2092
- }));
3170
+ const properties = mapping.properties.filter((property) => !excluded.has(property.source)).map((property) => {
3171
+ const leaf = property.target.includes(".") ? property.target.split(".").pop() : void 0;
3172
+ return {
3173
+ name: property.target,
3174
+ display_name: leaf ?? property.source,
3175
+ desc: property.desc ?? leaf ?? property.source,
3176
+ type: toPropType(property.type),
3177
+ source: "data"
3178
+ };
3179
+ });
2093
3180
  const propNames = properties.map((property) => property.name);
2094
3181
  const eventNames = resolveEventNames(options);
2095
3182
  const events = eventNames.map((eventName) => {
@@ -2167,7 +3254,7 @@ function buildPlanDraft(ctx) {
2167
3254
  location: { field: "lang" }
2168
3255
  });
2169
3256
  }
2170
- return buildDraftFromMapping({
3257
+ const draft = buildDraftFromMapping({
2171
3258
  mapping,
2172
3259
  planName: ctx.str("plan-name").trim() || mapping.default_event_name || "local-data",
2173
3260
  eventNames: ctx.list("event-name"),
@@ -2175,6 +3262,17 @@ function buildPlanDraft(ctx) {
2175
3262
  lang,
2176
3263
  projectId: ctx.optionalNum("project-id")
2177
3264
  });
3265
+ validateAndFix(draft);
3266
+ try {
3267
+ validateDraft(draft);
3268
+ } catch (error) {
3269
+ throw new CliValidationError("The tracking-plan draft is invalid.", {
3270
+ code: "LOCAL_DATA_PLAN_INVALID_DRAFT",
3271
+ hint: error instanceof Error ? error.message : String(error),
3272
+ location: { field: "mapping" }
3273
+ });
3274
+ }
3275
+ return draft;
2178
3276
  }
2179
3277
  var dataIntegrationPlan = {
2180
3278
  service: "data-integration",
@@ -2182,7 +3280,7 @@ var dataIntegrationPlan = {
2182
3280
  usesAeHost: false,
2183
3281
  description: "Convert a confirmed local-data mapping into a tracking-plan draft.json (source_type=data, sdk_integration_mode=none).",
2184
3282
  flags: [
2185
- { name: "mapping", type: "string", required: true, sensitive: true, desc: "Confirmed ae-local-data-mapping/v1 JSON, file path, or @file." },
3283
+ { name: "mapping", type: "string", required: true, sensitive: true, desc: `Confirmed ${MAPPING_VERSION} JSON, file path, or @file.` },
2186
3284
  { name: "event-name", type: "string", variadic: true, desc: "Concrete event name (track/mixed without default_event_name). Repeat for multiple events." },
2187
3285
  { name: "plan-name", type: "string", desc: 'Plan name. Default: the mapping default_event_name, else "local-data".' },
2188
3286
  { name: "app-type", type: "string", default: "unknown", desc: "Application type recorded in draft meta (informational)." },
@@ -2213,7 +3311,7 @@ var dataIntegrationPlan = {
2213
3311
  }
2214
3312
  };
2215
3313
 
2216
- // src/commands/data-integration/local-data/conversion.ts
3314
+ // src/commands/data-integration/conversion.ts
2217
3315
  import { randomInt as randomInt2, randomUUID } from "crypto";
2218
3316
  import {
2219
3317
  chmodSync,
@@ -2234,14 +3332,6 @@ import { createInterface as createInterface2 } from "readline";
2234
3332
  var SORT_CHUNK_SIZE = 1e4;
2235
3333
  var THREE_YEARS_MS = 3 * 365 * 24 * 60 * 60 * 1e3;
2236
3334
  var THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1e3;
2237
- function stripQuotes(value) {
2238
- if (typeof value !== "string") return value;
2239
- const text = value;
2240
- if (text.length >= 2 && (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'"))) {
2241
- return text.slice(1, -1).trim();
2242
- }
2243
- return text.trim();
2244
- }
2245
3335
  async function convertLocalData(options) {
2246
3336
  const input = await inspectLocalDataInput(options.inputFile);
2247
3337
  if (input.format !== options.mapping.source.format) {
@@ -2260,13 +3350,29 @@ async function convertLocalData(options) {
2260
3350
  const dataSet = options.mergeSheets ? { id: "all-sheets", kind: "sheet", label: "all-sheets", selector: "all-sheets" } : selectDataSet(input, options.mapping.source.data_set);
2261
3351
  const streamOptions = {
2262
3352
  headerNames: options.mapping.headers,
3353
+ skipRows: options.mapping.skip_rows,
2263
3354
  flattenRules: options.mapping.flatten_rules,
2264
- mergeSheets: options.mergeSheets
3355
+ mergeSheets: options.mergeSheets,
3356
+ // Both XLSX layout decisions come from the mapping, so the rows converted here are the rows
3357
+ // inspect profiled — the mapping is the only place the user's answer to them is recorded.
3358
+ fillMergedCells: options.mapping.fill_merged_cells,
3359
+ excludeHiddenRows: options.mapping.exclude_hidden_rows,
3360
+ // The profile pass inside convert is internal (it writes profile.json); the ragged-row
3361
+ // warning is surfaced by the conversion pass below instead, so suppress it here.
3362
+ warnRagged: false,
3363
+ // The mapping's own columns are the key worth checking for repeats: they are what AE will
3364
+ // receive as identity, time, and event name. A file-wide `default_event_name` is the same on
3365
+ // every row, so it distinguishes nothing and is left out.
3366
+ duplicateKeyFields: [
3367
+ options.mapping.account_id_field ?? options.mapping.distinct_id_field,
3368
+ options.mapping.time.field,
3369
+ options.mapping.event_name_field
3370
+ ].filter((field) => typeof field === "string" && field.length > 0)
2265
3371
  };
2266
3372
  const salvageSet = options.salvageFrom ? readSalvageRowNumbers(options.salvageFrom) : void 0;
2267
3373
  let salvageMatched = 0;
2268
3374
  const runId = `${formatRunTimestamp(/* @__PURE__ */ new Date())}-${randomUUID().slice(0, 8)}`;
2269
- const outputDir = resolve(options.outputDir || join(".ae-cli", "data-integration", runId));
3375
+ const outputDir = resolve(options.outputDir || join(".ae-cli", "data-integration", "runs", runId));
2270
3376
  prepareOutputDirectory(outputDir);
2271
3377
  const profile = await profileLocalData(input, dataSet, options.mapping.time.source_timezone, streamOptions);
2272
3378
  const profilePath = join(outputDir, "profile.json");
@@ -2283,7 +3389,12 @@ async function convertLocalData(options) {
2283
3389
  let validRecords = 0;
2284
3390
  let invalidRecords = 0;
2285
3391
  const recordTypes = {};
2286
- await streamLocalDataRows(
3392
+ const skippedFields = {};
3393
+ let lanIpRecords = 0;
3394
+ const flattenMisses = {};
3395
+ const cellIssues = /* @__PURE__ */ new Map();
3396
+ const xlsxStructure = input.format === "xlsx" ? createXlsxStructureCollector() : void 0;
3397
+ const rowCount = await streamLocalDataRows(
2287
3398
  input,
2288
3399
  dataSet,
2289
3400
  async (row, rowNumber) => {
@@ -2297,6 +3408,10 @@ async function convertLocalData(options) {
2297
3408
  }
2298
3409
  validRecords += 1;
2299
3410
  recordTypes[result.recordType] = (recordTypes[result.recordType] ?? 0) + 1;
3411
+ if (result.lanIp) lanIpRecords += 1;
3412
+ for (const skip of result.skips) {
3413
+ skippedFields[skip.code] = (skippedFields[skip.code] ?? 0) + 1;
3414
+ }
2300
3415
  const line = JSON.stringify(result.record);
2301
3416
  if (isUserProfileType(result.recordType)) {
2302
3417
  userSetBuffer.push({ key: result.sortKey, line });
@@ -2307,14 +3422,33 @@ async function convertLocalData(options) {
2307
3422
  },
2308
3423
  {
2309
3424
  headerNames: streamOptions.headerNames,
3425
+ skipRows: streamOptions.skipRows,
2310
3426
  flattenRules: streamOptions.flattenRules,
2311
- mergeSheets: streamOptions.mergeSheets
3427
+ flattenMisses,
3428
+ cellIssues,
3429
+ mergeSheets: streamOptions.mergeSheets,
3430
+ fillMergedCells: streamOptions.fillMergedCells,
3431
+ excludeHiddenRows: streamOptions.excludeHiddenRows,
3432
+ xlsxStructure
2312
3433
  }
2313
3434
  );
2314
3435
  if (userSetBuffer.length > 0) flushSortChunk(outputDir, userSetBuffer, sortChunks);
2315
- trackStream.end();
2316
- invalidStream.end();
2317
- await Promise.all([once(trackStream, "finish"), once(invalidStream, "finish")]);
3436
+ await Promise.all([finishStream(trackStream), finishStream(invalidStream)]);
3437
+ for (const [outColumn, count] of Object.entries(flattenMisses)) {
3438
+ process.stderr.write(`Warning: flatten rule "${outColumn}" did not materialize for ${count} row(s).
3439
+ `);
3440
+ }
3441
+ for (const warning of cellIssueWarnings(cellIssues)) {
3442
+ process.stderr.write(`Warning: ${warning}
3443
+ `);
3444
+ }
3445
+ if (xlsxStructure) {
3446
+ for (const warning of xlsxStructureWarnings(xlsxStructure, options.mapping.exclude_columns)) {
3447
+ process.stderr.write(`Warning: ${warning}
3448
+ `);
3449
+ }
3450
+ }
3451
+ const structureReport = xlsxStructure ? xlsxStructureReport(xlsxStructure) : void 0;
2318
3452
  if (salvageSet && salvageMatched === 0) {
2319
3453
  throw new CliValidationError("The salvage file lists no rows from this source.", {
2320
3454
  code: "LOCAL_DATA_SALVAGE_NO_MATCH",
@@ -2325,21 +3459,22 @@ async function convertLocalData(options) {
2325
3459
  const validStream = secureWriteStream(validPath);
2326
3460
  if (existsSync(trackTempPath)) {
2327
3461
  for await (const chunk of createReadStream3(trackTempPath)) {
2328
- if (!validStream.write(chunk)) await once(validStream, "drain");
3462
+ await writeRaw(validStream, chunk);
2329
3463
  }
2330
3464
  }
2331
3465
  await mergeSortChunks(sortChunks, validStream);
2332
- validStream.end();
2333
- await once(validStream, "finish");
3466
+ await finishStream(validStream);
2334
3467
  if (existsSync(trackTempPath)) unlinkSync(trackTempPath);
2335
3468
  for (const path of sortChunks) if (existsSync(path)) unlinkSync(path);
2336
3469
  writeSecureJson(profilePath, profile);
2337
3470
  writeSecureJson(mappingPath, options.mapping);
2338
3471
  writeSecureText(transformPath, createTransformScript(options.inputFile, mappingPath));
2339
3472
  const validBytes = statSize(validPath);
3473
+ const sourceRows = salvageSet ? salvageMatched : rowCount;
2340
3474
  const blockedReasons = [
2341
- ...invalidRecords > 0 ? ["Some source rows failed UE validation."] : [],
2342
- ...validRecords === 0 ? ["No valid UE records were generated."] : []
3475
+ ...rowCount === 0 ? ["The source contained no data rows."] : [],
3476
+ ...rowCount > 0 && validRecords === 0 ? ["No valid UE records were generated."] : [],
3477
+ ...invalidRecords > 0 ? ["Some source rows failed UE validation."] : []
2343
3478
  ];
2344
3479
  const manifest = {
2345
3480
  version: "ae-local-data-manifest/v1",
@@ -2357,10 +3492,18 @@ async function convertLocalData(options) {
2357
3492
  valid_file: basename4(validPath),
2358
3493
  valid_sha256: await sha256File(validPath),
2359
3494
  invalid_file: basename4(invalidPath),
3495
+ source_rows: sourceRows,
2360
3496
  valid_records: validRecords,
2361
3497
  invalid_records: invalidRecords,
2362
3498
  valid_bytes: validBytes,
2363
- record_types: recordTypes
3499
+ record_types: recordTypes,
3500
+ ...Object.keys(skippedFields).length > 0 ? { skipped_fields: skippedFields } : {},
3501
+ ...lanIpRecords > 0 ? { lan_ip_records: lanIpRecords } : {},
3502
+ ...Object.keys(flattenMisses).length > 0 ? { flatten_misses: flattenMisses } : {},
3503
+ ...cellIssues.size > 0 ? { unreadable_cells: cellIssueCounts(cellIssues) } : {},
3504
+ ...structureReport ? { xlsx_structure: structureReport } : {},
3505
+ ...profile.summary_rows ? { summary_rows: profile.summary_rows } : {},
3506
+ ...profile.duplicate_keys ? { duplicate_keys: profile.duplicate_keys } : {}
2364
3507
  },
2365
3508
  blocked_reasons: blockedReasons
2366
3509
  };
@@ -2378,7 +3521,9 @@ async function convertLocalDataMulti(options) {
2378
3521
  const profile = await profileLocalData(input, dataSet, sourceTimezone, {
2379
3522
  collectSamples: true,
2380
3523
  headerNames: options.mapping.headers,
2381
- flattenRules: options.mapping.flatten_rules
3524
+ skipRows: options.mapping.skip_rows,
3525
+ flattenRules: options.mapping.flatten_rules,
3526
+ warnRagged: false
2382
3527
  });
2383
3528
  profiled.push({ file: basename4(inputFile), profile });
2384
3529
  }
@@ -2394,7 +3539,7 @@ async function convertLocalDataMulti(options) {
2394
3539
  validateTypeResolutions(resolutions, profiled.map((entry) => entry.file));
2395
3540
  const overrides = applyTypeResolutions(resolutions, profiled);
2396
3541
  const parent = resolve(
2397
- options.outputDir || join(".ae-cli", "data-integration", `${formatRunTimestamp(/* @__PURE__ */ new Date())}-${randomUUID().slice(0, 8)}`)
3542
+ options.outputDir || join(".ae-cli", "data-integration", "runs", `${formatRunTimestamp(/* @__PURE__ */ new Date())}-${randomUUID().slice(0, 8)}`)
2398
3543
  );
2399
3544
  prepareOutputDirectory(parent);
2400
3545
  const files = [];
@@ -2452,11 +3597,32 @@ function convertRow(row, rowNumber, mapping, now) {
2452
3597
  errors.push({ code: "INVALID_EVENT_NAME", field: mapping.event_name_field });
2453
3598
  }
2454
3599
  }
2455
- const ip = readOptionalField(row, mapping.ip_field);
2456
- const uuid = readOptionalField(row, mapping.uuid_field);
3600
+ const skips = [];
3601
+ let lanIp = false;
3602
+ let ip;
3603
+ if (recordType === "track") {
3604
+ const rawIp = readOptionalField(row, mapping.ip_field);
3605
+ if (rawIp) {
3606
+ if (isValidIp(rawIp)) {
3607
+ ip = rawIp;
3608
+ lanIp = isPrivateIp(rawIp);
3609
+ } else {
3610
+ skips.push({ code: "INVALID_IP", field: mapping.ip_field });
3611
+ }
3612
+ }
3613
+ }
3614
+ let uuid;
3615
+ {
3616
+ const rawUuid = readOptionalField(row, mapping.uuid_field);
3617
+ if (rawUuid) {
3618
+ if (isValidUuid(rawUuid)) uuid = rawUuid;
3619
+ else skips.push({ code: "INVALID_UUID", field: mapping.uuid_field });
3620
+ }
3621
+ }
2457
3622
  const excluded = new Set(mapping.exclude_columns ?? []);
2458
3623
  const properties = {};
2459
3624
  for (const property of mapping.properties) {
3625
+ if (property.target.includes(".")) continue;
2460
3626
  if (excluded.has(property.source)) continue;
2461
3627
  let value = stripQuotes(row[property.source]);
2462
3628
  if (isMissing2(value)) continue;
@@ -2470,8 +3636,8 @@ function convertRow(row, rowNumber, mapping, now) {
2470
3636
  properties[property.target] = converted.value;
2471
3637
  }
2472
3638
  }
2473
- const zoneOffset = mapping.zone_offset_value !== void 0 ? resolveZoneOffsetValue(mapping.zone_offset_value, now) : mapping.zone_offset_field ? readZoneOffset(row, mapping.zone_offset_field) : void 0;
2474
- if (mapping.zone_offset_field && zoneOffset === void 0) {
3639
+ const zoneOffset = recordType === "track" && mapping.zone_offset_value !== void 0 ? resolveZoneOffsetValue(mapping.zone_offset_value, now) : recordType === "track" && mapping.zone_offset_field ? readZoneOffset(row, mapping.zone_offset_field) : void 0;
3640
+ if (recordType === "track" && mapping.zone_offset_field && zoneOffset === void 0) {
2475
3641
  errors.push({ code: "INVALID_ZONE_OFFSET", field: mapping.zone_offset_field });
2476
3642
  }
2477
3643
  if (errors.length > 0 || !recordType || !normalizedTime) return { ok: false, errors };
@@ -2489,7 +3655,9 @@ function convertRow(row, rowNumber, mapping, now) {
2489
3655
  ok: true,
2490
3656
  recordType,
2491
3657
  record,
2492
- sortKey: `${accountId ?? distinctId ?? ""}\0${normalizedTime.instant.toISOString()}\0${String(rowNumber).padStart(12, "0")}`
3658
+ sortKey: `${accountId ?? distinctId ?? ""}\0${normalizedTime.instant.toISOString()}\0${String(rowNumber).padStart(12, "0")}`,
3659
+ skips,
3660
+ lanIp
2493
3661
  };
2494
3662
  }
2495
3663
  function readOptionalField(row, field) {
@@ -2597,7 +3765,7 @@ function convertProperty(value, type, transform, timeZone = "UTC", timeFormat) {
2597
3765
  const normalized = normalizeTime(value, timeZone, timeFormat);
2598
3766
  return normalized ? { ok: true, value: normalized.formatted } : { ok: false, code: "PROPERTY_TYPE_CONFLICT" };
2599
3767
  }
2600
- if (type === "list") {
3768
+ if (type === "list" || type === "array_row") {
2601
3769
  if (!Array.isArray(value)) return { ok: false, code: "PROPERTY_TYPE_CONFLICT" };
2602
3770
  return isPropertyWithinLimits(value, type) ? { ok: true, value } : { ok: false, code: "PROPERTY_LIMIT_EXCEEDED" };
2603
3771
  }
@@ -2799,11 +3967,40 @@ function prepareOutputDirectory(path) {
2799
3967
  chmodSync(path, 448);
2800
3968
  }
2801
3969
  function secureWriteStream(path) {
2802
- return createWriteStream(path, { encoding: "utf8", mode: 384, flags: "wx" });
3970
+ const stream = createWriteStream(path, { encoding: "utf8", mode: 384, flags: "wx" });
3971
+ let fail;
3972
+ const errorPromise = new Promise((_, reject) => {
3973
+ fail = reject;
3974
+ });
3975
+ errorPromise.catch(() => {
3976
+ });
3977
+ stream.on("error", (error) => fail?.(error));
3978
+ return { stream, path, errorPromise };
3979
+ }
3980
+ function writeFailure(path, error) {
3981
+ const detail = error instanceof Error ? error.message : String(error);
3982
+ return new Error(`Failed to write "${path}": ${detail}. Check available disk space and directory permissions.`, { cause: error });
2803
3983
  }
2804
- async function writeLine(stream, line) {
2805
- if (!stream.write(`${line}
2806
- `)) await once(stream, "drain");
3984
+ async function writeRaw(target, data) {
3985
+ try {
3986
+ if (!target.stream.write(data)) {
3987
+ await Promise.race([once(target.stream, "drain"), target.errorPromise]);
3988
+ }
3989
+ } catch (error) {
3990
+ throw writeFailure(target.path, error);
3991
+ }
3992
+ }
3993
+ async function writeLine(target, line) {
3994
+ await writeRaw(target, `${line}
3995
+ `);
3996
+ }
3997
+ async function finishStream(target) {
3998
+ try {
3999
+ target.stream.end();
4000
+ await Promise.race([once(target.stream, "finish"), target.errorPromise]);
4001
+ } catch (error) {
4002
+ throw writeFailure(target.path, error);
4003
+ }
2807
4004
  }
2808
4005
  function writeSecureJson(path, value) {
2809
4006
  writeSecureText(path, `${JSON.stringify(value, null, 2)}
@@ -2830,7 +4027,7 @@ function formatRunTimestamp(value) {
2830
4027
  return value.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
2831
4028
  }
2832
4029
 
2833
- // src/commands/data-integration/local-data/convert.ts
4030
+ // src/commands/data-integration/convert.ts
2834
4031
  var dataIntegrationConvert = {
2835
4032
  service: "data-integration",
2836
4033
  command: "convert",
@@ -2838,8 +4035,8 @@ var dataIntegrationConvert = {
2838
4035
  description: "Convert one or more local data sets into validated UE JSONL and quarantine invalid rows.",
2839
4036
  flags: [
2840
4037
  { name: "input-file", type: "string", required: true, sensitive: true, variadic: true, desc: "Source local data file. Repeat for multiple files (requires a wildcard mapping). The source is never modified." },
2841
- { name: "mapping", type: "string", required: true, sensitive: true, desc: "ae-local-data-mapping/v1 JSON, file path, or @file." },
2842
- { name: "output-dir", type: "string", sensitive: true, desc: "New or empty output directory. Default: .ae-cli/data-integration/<run-id>." },
4038
+ { name: "mapping", type: "string", required: true, sensitive: true, desc: `${MAPPING_VERSION} JSON, file path, or @file.` },
4039
+ { name: "output-dir", type: "string", sensitive: true, desc: "New or empty output directory. Default: .ae-cli/data-integration/runs/<run-id>." },
2843
4040
  { name: "type-resolutions", type: "json", sensitive: true, desc: "JSON object resolving cross-file column type conflicts (unify, split, or skip)." },
2844
4041
  { name: "merge-sheets", type: "boolean", default: false, desc: "Stream every worksheet in file order instead of a single selected sheet." },
2845
4042
  { name: "salvage-from", type: "string", sensitive: true, desc: "Re-process only the rows listed in a previous run's invalid.rows.jsonl, against the current (fixed) mapping. Single-file only." }
@@ -2891,7 +4088,7 @@ var dataIntegrationConvert = {
2891
4088
  }
2892
4089
  };
2893
4090
 
2894
- // src/commands/data-integration/local-data/upload.ts
4091
+ // src/commands/data-integration/upload.ts
2895
4092
  import { createReadStream as createReadStream4, readFileSync as readFileSync3, statSync as statSync3 } from "fs";
2896
4093
  import { basename as basename5, dirname, resolve as resolve2 } from "path";
2897
4094
  import { createInterface as createInterface3 } from "readline";
@@ -3236,22 +4433,900 @@ function isRecord3(value) {
3236
4433
  return value !== null && typeof value === "object" && !Array.isArray(value);
3237
4434
  }
3238
4435
 
3239
- // src/commands/data-integration/local-data/handoff.ts
4436
+ // src/commands/data-integration/handoff.ts
3240
4437
  import { createHash as createHash3 } from "crypto";
3241
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "fs";
3242
- import { join as join2, resolve as resolve3 } from "path";
4438
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, mkdtempSync, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
4439
+ import { dirname as dirname2, join as join3, resolve as resolve3 } from "path";
4440
+ import { tmpdir } from "os";
4441
+
4442
+ // src/commands/data-integration/archive.ts
4443
+ import { ZipArchive } from "archiver";
4444
+ import { createWriteStream as createWriteStream2, readdirSync as readdirSync2, statSync as statSync4 } from "fs";
4445
+ import { join as join2, relative, sep } from "path";
4446
+ async function zipPackage(dir, zipPath) {
4447
+ await new Promise((resolvePromise, rejectPromise) => {
4448
+ const output = createWriteStream2(zipPath, { mode: 384 });
4449
+ const zip = new ZipArchive({ zlib: { level: 9 } });
4450
+ output.on("close", resolvePromise);
4451
+ output.on("error", rejectPromise);
4452
+ zip.on("error", rejectPromise);
4453
+ zip.on("warning", (error) => {
4454
+ if (error.code !== "ENOENT") rejectPromise(error);
4455
+ });
4456
+ zip.pipe(output);
4457
+ const walk = (current) => {
4458
+ for (const name of readdirSync2(current)) {
4459
+ if (name === ".DS_Store") continue;
4460
+ const full = join2(current, name);
4461
+ const stats = statSync4(full);
4462
+ if (stats.isDirectory()) {
4463
+ walk(full);
4464
+ } else {
4465
+ const rel = relative(dir, full).split(sep).join("/");
4466
+ zip.file(full, { name: rel, mode: stats.mode & 511 });
4467
+ }
4468
+ }
4469
+ };
4470
+ walk(dir);
4471
+ void zip.finalize();
4472
+ });
4473
+ }
4474
+
4475
+ // src/commands/data-integration/relay.ts
4476
+ var PIPELINE_VERSION = "ae-data-integration-pipeline/v1";
4477
+ var SHAPE_VERSION = "ae-data-integration-shape/v1";
4478
+ var DEFAULT_BATCH_SIZE2 = 500;
4479
+ var ENV_FILE = ".local/target.env";
4480
+ function buildPipelineDescriptor(entries, target = {}) {
4481
+ const first = entries[0];
4482
+ return {
4483
+ version: PIPELINE_VERSION,
4484
+ created_at: first?.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
4485
+ source: { type: "local_file", params: { format: first?.format ?? "csv" } },
4486
+ transform: { type: MAPPING_VERSION, refs: entries.map((entry) => entry.mapping_file) },
4487
+ sink: {
4488
+ type: "restful_sync_json",
4489
+ params: {
4490
+ batch_size: DEFAULT_BATCH_SIZE2,
4491
+ env_file: ENV_FILE,
4492
+ ...target.pushurl ? { pushurl: target.pushurl } : {},
4493
+ ...target.project_id ? { project_id: target.project_id } : {}
4494
+ }
4495
+ }
4496
+ };
4497
+ }
4498
+ function buildShapeBaseline(items) {
4499
+ return {
4500
+ version: SHAPE_VERSION,
4501
+ entries: items.map(({ mapping, fingerprint }) => ({
4502
+ fingerprint,
4503
+ mode: mapping.mode,
4504
+ data_set: mapping.source.data_set,
4505
+ format: mapping.source.format,
4506
+ columns: sourceColumns(mapping)
4507
+ }))
4508
+ };
4509
+ }
4510
+ function sh(...lines) {
4511
+ return `${lines.join("\n")}
4512
+ `;
4513
+ }
4514
+ function generateBinScripts() {
4515
+ return [
4516
+ { relPath: "bin/run.sh", content: runSh(), mode: 448 },
4517
+ { relPath: "bin/upload.sh", content: uploadSh(), mode: 448 },
4518
+ { relPath: "bin/bind_mapping.py", content: bindMappingPy(), mode: 448 },
4519
+ { relPath: "bin/summarize.py", content: summarizePy(), mode: 448 },
4520
+ { relPath: "bin/plan_check.py", content: planCheckPy(), mode: 448 },
4521
+ { relPath: "bin/verify.py", content: verifyPy(), mode: 448 },
4522
+ { relPath: "bin/resolve_appid.py", content: resolveAppidPy(), mode: 448 }
4523
+ ];
4524
+ }
4525
+ function runSh() {
4526
+ return sh(
4527
+ "#!/usr/bin/env bash",
4528
+ "# Generic pipeline executor: source -> transform -> plan. Never uploads (see upload.sh).",
4529
+ "# Reads pipeline.json and dispatches each stage by its `type` to ae-cli subcommands.",
4530
+ "set -euo pipefail",
4531
+ 'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"',
4532
+ 'PKG_ROOT="$(dirname "$SCRIPT_DIR")"',
4533
+ 'cd "$PKG_ROOT"',
4534
+ "",
4535
+ `SRC_TYPE="$(python3 -c 'import json; print(json.load(open("pipeline.json"))["source"]["type"])')"`,
4536
+ 'case "$SRC_TYPE" in',
4537
+ " local_file) ;;",
4538
+ " *)",
4539
+ ' echo "unsupported source type: $SRC_TYPE" >&2',
4540
+ ' echo "re-run the full ae-data-integration pipeline (or upgrade ae-cli) for this source." >&2',
4541
+ " exit 64",
4542
+ " ;;",
4543
+ "esac",
4544
+ "",
4545
+ 'INPUT="${1:-}"',
4546
+ 'if [ -z "$INPUT" ]; then',
4547
+ " shopt -s nullglob; FILES=(inbox/*); shopt -u nullglob",
4548
+ ' if [ "${#FILES[@]}" -ne 1 ]; then',
4549
+ ' echo "usage: bin/run.sh <input-file>" >&2',
4550
+ ' echo " (or put exactly one file in inbox/)" >&2',
4551
+ " exit 2",
4552
+ " fi",
4553
+ ' INPUT="${FILES[0]}"',
4554
+ "fi",
4555
+ "",
4556
+ 'RUN_DIR="runs/$(date +%Y%m%d-%H%M%S)"',
4557
+ 'mkdir -p "$RUN_DIR"',
4558
+ 'echo "run: $RUN_DIR"',
4559
+ "",
4560
+ 'python3 bin/bind_mapping.py "$INPUT" "$RUN_DIR"',
4561
+ "",
4562
+ "while IFS= read -r ref; do",
4563
+ ' ref_dir="$(dirname "$ref")"',
4564
+ ' echo "convert: $ref_dir"',
4565
+ " ae-cli data-integration convert \\",
4566
+ ' --input-file "$INPUT" \\',
4567
+ ' --mapping "$RUN_DIR/bound/$ref_dir/mapping.json" \\',
4568
+ ' --output-dir "$RUN_DIR/$ref_dir" || exit $?',
4569
+ `done < <(python3 -c 'import json,sys; print("\\n".join(json.load(open("pipeline.json"))["transform"]["refs"]))')`,
4570
+ "",
4571
+ 'python3 bin/summarize.py "$RUN_DIR"',
4572
+ 'python3 bin/plan_check.py "$RUN_DIR"',
4573
+ "",
4574
+ "# Salvage hint: quarantined rows are never silently dropped. Re-process only",
4575
+ "# them against the fixed mapping instead of re-uploading the whole file.",
4576
+ "while IFS= read -r ref; do",
4577
+ ' ref_dir="$(dirname "$ref")"',
4578
+ ' inv="$RUN_DIR/$ref_dir/invalid.rows.jsonl"',
4579
+ ' if [ -s "$inv" ]; then',
4580
+ ' echo "note: $inv has quarantined rows \u2014 salvage them with:"',
4581
+ ' echo " ae-cli data-integration convert --input-file "$INPUT" --mapping "$RUN_DIR/bound/$ref_dir/mapping.json" --salvage-from "$inv" --output-dir "$RUN_DIR-salvage""',
4582
+ " fi",
4583
+ `done < <(python3 -c 'import json,sys; print("\\n".join(json.load(open("pipeline.json"))["transform"]["refs"]))')`,
4584
+ "",
4585
+ 'echo "done. review the summary and plan check, then run: bin/upload.sh $RUN_DIR"'
4586
+ );
4587
+ }
4588
+ function uploadSh() {
4589
+ return sh(
4590
+ "#!/usr/bin/env bash",
4591
+ "# Sink executor. Dry-run by default; --confirm actually uploads.",
4592
+ "# Resolves the recorded target from pipeline.json (pushurl + project_id), derives",
4593
+ "# the APPID via `ae-cli project info get` (bin/resolve_appid.py), and falls back to",
4594
+ "# .local/target.env for explicit APPID / endpoint / project-id overrides.",
4595
+ "set -euo pipefail",
4596
+ 'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"',
4597
+ 'PKG_ROOT="$(dirname "$SCRIPT_DIR")"',
4598
+ 'cd "$PKG_ROOT"',
4599
+ "",
4600
+ `SINK_TYPE="$(python3 -c 'import json; print(json.load(open("pipeline.json"))["sink"]["type"])')"`,
4601
+ 'case "$SINK_TYPE" in',
4602
+ " restful_sync_json) ;;",
4603
+ " *)",
4604
+ ' echo "unsupported sink type: $SINK_TYPE" >&2',
4605
+ ' echo "re-run the full ae-data-integration pipeline (or upgrade ae-cli) for this sink." >&2',
4606
+ " exit 64",
4607
+ " ;;",
4608
+ "esac",
4609
+ "",
4610
+ `SINK_PARAMS="$(python3 -c 'import json; print(json.dumps(json.load(open("pipeline.json"))["sink"]["params"]))')"`,
4611
+ `BATCH_SIZE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("batch_size", 500))' "$SINK_PARAMS")"`,
4612
+ `ENV_FILE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("env_file", ".local/target.env"))' "$SINK_PARAMS")"`,
4613
+ `PUSHURL="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("pushurl") or "")' "$SINK_PARAMS")"`,
4614
+ `PROJECT_ID="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("project_id") or "")' "$SINK_PARAMS")"`,
4615
+ "",
4616
+ "CONFIRM=0",
4617
+ 'RUN_DIR=""',
4618
+ 'for arg in "$@"; do',
4619
+ ' case "$arg" in',
4620
+ " --confirm) CONFIRM=1 ;;",
4621
+ ' -*) echo "unknown flag: $arg" >&2; exit 2 ;;',
4622
+ ' *) RUN_DIR="$arg" ;;',
4623
+ " esac",
4624
+ "done",
4625
+ "",
4626
+ 'if [ -z "$RUN_DIR" ]; then',
4627
+ ' echo "usage: bin/upload.sh [--confirm] <runs/<run-id>>" >&2',
4628
+ " exit 2",
4629
+ "fi",
4630
+ "",
4631
+ "# .local/target.env is optional: the package may record the target itself.",
4632
+ "# Env values still win as explicit overrides (the documented fallback).",
4633
+ 'if [ -f "$ENV_FILE" ]; then set -a; . "$ENV_FILE"; set +a; fi',
4634
+ '[ -z "$PROJECT_ID" ] && PROJECT_ID="${AE_PROJECT_ID:-}"',
4635
+ "",
4636
+ "# Endpoint: the recorded pushurl (a receiver base URL; append /sync_json), else AE_ENDPOINT.",
4637
+ 'if [ -n "$PUSHURL" ]; then',
4638
+ ' case "$PUSHURL" in',
4639
+ ' */sync_json) ENDPOINT="$PUSHURL" ;;',
4640
+ ' *) ENDPOINT="${PUSHURL%/}/sync_json" ;;',
4641
+ " esac",
4642
+ "else",
4643
+ ' : "${AE_ENDPOINT:?set AE_ENDPOINT in .local/target.env (or record pushurl at handoff)}"',
4644
+ ' ENDPOINT="$AE_ENDPOINT"',
4645
+ "fi",
4646
+ "",
4647
+ "# APPID: explicit env wins, else derive from the recorded project via project info get.",
4648
+ 'APPID="${AE_APPID:-}"',
4649
+ 'if [ -z "$APPID" ] && [ -n "$PROJECT_ID" ]; then',
4650
+ ' APPID="$(python3 bin/resolve_appid.py "$PROJECT_ID")"',
4651
+ "fi",
4652
+ ': "${APPID:?set AE_APPID in .local/target.env (or record project_id at handoff)}"',
4653
+ "",
4654
+ "# Mask the APPID in anything this script prints \u2014 it must never land in logs.",
4655
+ "mask() {",
4656
+ ' local a="$1"',
4657
+ ' if [ "${#a}" -le 4 ]; then printf "%s" "****"; return; fi',
4658
+ ' printf "%s%s" "$(printf "%*s" "$(( ${#a} - 4 ))" "" | tr " " "*")" "${a: -4}"',
4659
+ "}",
4660
+ "display_args() {",
4661
+ ' local args=("$@") out=() i',
4662
+ " for ((i=0; i<${#args[@]}; i++)); do",
4663
+ ' if [ "${args[$i]}" = "--appid" ] && [ -n "${args[$((i+1))]:-}" ]; then',
4664
+ ' out+=("--appid" "$(mask "${args[$((i+1))]}")")',
4665
+ " i=$((i+1))",
4666
+ " else",
4667
+ ' out+=("${args[$i]}")',
4668
+ " fi",
4669
+ " done",
4670
+ ' printf "%s\\n" "${out[*]}"',
4671
+ "}",
4672
+ "",
4673
+ 'echo "target: project_id=${PROJECT_ID:-<unset>}"',
4674
+ 'echo " endpoint=$ENDPOINT"',
4675
+ 'echo " appid=$(mask "$APPID")"',
4676
+ 'if [ "$CONFIRM" -eq 0 ]; then',
4677
+ ' echo "dry-run \u2014 re-run with --confirm to upload to this address and project."',
4678
+ "else",
4679
+ ' echo "confirmed: uploading to the address and project shown above."',
4680
+ "fi",
4681
+ "",
4682
+ 'FLAGS=(--endpoint "$ENDPOINT" --appid "$APPID" --batch-size "$BATCH_SIZE")',
4683
+ 'if [ "$CONFIRM" -eq 0 ]; then FLAGS+=(--dry-run); fi',
4684
+ "",
4685
+ "while IFS= read -r ref; do",
4686
+ ' ref_dir="$(dirname "$ref")"',
4687
+ ' ue="$RUN_DIR/$ref_dir/valid.ue.jsonl"',
4688
+ ' manifest="$RUN_DIR/$ref_dir/manifest.json"',
4689
+ ' if [ ! -f "$ue" ]; then',
4690
+ ' echo "missing $ue (run bin/run.sh first)" >&2',
4691
+ " exit 2",
4692
+ " fi",
4693
+ ` status="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["status"])' "$manifest")"`,
4694
+ ' args=("${FLAGS[@]}" --ue-file "$ue" --manifest "$manifest")',
4695
+ ' if [ "$status" = "blocked" ]; then',
4696
+ ' echo "manifest $manifest is blocked (rows were quarantined)."',
4697
+ ' echo "Uploading passes only the valid subset (--allow-clean-subset); quarantined rows stay in invalid.rows.jsonl."',
4698
+ ' if [ "$CONFIRM" -eq 1 ]; then args+=(--allow-clean-subset); else echo " (dry-run) re-run with --confirm to accept the clean subset."; fi',
4699
+ " fi",
4700
+ ' echo "> ae-cli data-integration upload $(display_args "${args[@]}")"',
4701
+ ' ae-cli data-integration upload "${args[@]}" || exit $?',
4702
+ `done < <(python3 -c 'import json,sys; print("\\n".join(json.load(open("pipeline.json"))["transform"]["refs"]))')`
4703
+ );
4704
+ }
4705
+ function bindMappingPy() {
4706
+ return `#!/usr/bin/env python3
4707
+ """Source stage: rebind the frozen mappings to a new same-shape file.
4708
+
4709
+ Runs \`ae-cli data-integration inspect\` once, then for every mapping the
4710
+ pipeline references (pipeline.json's transform.refs) re-binds the frozen
4711
+ mapping's \`source.sha256\` and \`source.data_set\` to the new file (identity
4712
+ fields only \u2014 business logic is untouched), after checking the column set
4713
+ against shape.json. Historical index entries the pipeline does not run are
4714
+ left alone \u2014 the index accumulates across handoffs in the same directory.
4715
+
4716
+ Usage: bin/bind_mapping.py <input-file> <run-dir>
4717
+ """
4718
+ import json
4719
+ import os
4720
+ import subprocess
4721
+ import sys
4722
+
4723
+
4724
+ def fail(message):
4725
+ print(f"bind_mapping: {message}", file=sys.stderr)
4726
+ sys.exit(1)
4727
+
4728
+
4729
+ def pkg_root():
4730
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
4731
+
4732
+
4733
+ def load_json(path):
4734
+ with open(path, "r", encoding="utf-8") as f:
4735
+ return json.load(f)
4736
+
4737
+
4738
+ def write_json(path, value):
4739
+ os.makedirs(os.path.dirname(path), exist_ok=True)
4740
+ with open(path, "w", encoding="utf-8") as f:
4741
+ json.dump(value, f, ensure_ascii=False, indent=2)
4742
+ f.write("\\n")
4743
+
4744
+
4745
+ def dataset_key(dataset):
4746
+ return dataset.get("id") or dataset.get("label") or ""
4747
+
4748
+
4749
+ def main():
4750
+ if len(sys.argv) != 3:
4751
+ fail("usage: bind_mapping.py <input-file> <run-dir>")
4752
+ input_file, run_dir = sys.argv[1], sys.argv[2]
4753
+ root = pkg_root()
4754
+
4755
+ pipeline = load_json(os.path.join(root, "pipeline.json"))
4756
+ if pipeline["source"]["type"] != "local_file":
4757
+ fail(f"unsupported source type: {pipeline['source']['type']}")
4758
+
4759
+ index = load_json(os.path.join(root, "index.json"))
4760
+ shape = load_json(os.path.join(root, "shape.json"))
4761
+ shape_by_fp = {entry["fingerprint"]: entry for entry in shape["entries"]}
4762
+ index_by_ref = {entry["mapping_file"]: entry for entry in index["entries"]}
4763
+
4764
+ inspect = run_inspect(input_file)
4765
+ datasets = extract_datasets(inspect)
4766
+ headers_by_dataset = extract_headers(inspect, datasets)
4767
+ sha = (inspect.get("source") or {}).get("sha256")
4768
+
4769
+ # Rebind only the mappings this pipeline runs (transform.refs). The index
4770
+ # accumulates entries across handoffs in the same directory; earlier entries
4771
+ # may have no shape baseline here and are never converted by run.sh, so
4772
+ # walking the whole index would fail on them.
4773
+ for ref in pipeline["transform"]["refs"]:
4774
+ entry = index_by_ref.get(ref)
4775
+ if entry is None:
4776
+ fail(f"index entry missing for {ref}; re-run the full pipeline")
4777
+ fingerprint = entry["fingerprint"]
4778
+ baseline = shape_by_fp.get(fingerprint)
4779
+ if baseline is None:
4780
+ fail(f"shape baseline missing for {fingerprint}; re-run the full pipeline")
4781
+ frozen = load_json(os.path.join(root, ref))
4782
+ data_set_id = match_dataset(frozen, baseline, datasets, headers_by_dataset)
4783
+ validate_headers(data_set_id, baseline, headers_by_dataset)
4784
+ frozen["source"]["sha256"] = sha
4785
+ frozen["source"]["data_set"] = data_set_id
4786
+ out = os.path.join(root, run_dir, "bound", os.path.dirname(ref), "mapping.json")
4787
+ write_json(out, frozen)
4788
+ print(f"rebound {os.path.dirname(ref)} -> data_set {data_set_id!r}")
4789
+ print("shape check passed")
4790
+
4791
+
4792
+ def run_inspect(input_file):
4793
+ proc = subprocess.run(
4794
+ ["ae-cli", "data-integration", "inspect", "--input-file", input_file],
4795
+ capture_output=True, text=True,
4796
+ )
4797
+ if proc.returncode != 0:
4798
+ fail(f"inspect failed: {proc.stderr.strip()}")
4799
+ try:
4800
+ parsed = json.loads(proc.stdout)
4801
+ except json.JSONDecodeError:
4802
+ fail("inspect returned non-JSON output")
4803
+ # ae-cli wraps every command result in { ok, data, error }; unwrap it.
4804
+ data = parsed.get("data") if isinstance(parsed, dict) else None
4805
+ if not isinstance(data, dict):
4806
+ fail("inspect returned no data payload")
4807
+ return data
4808
+
4809
+
4810
+ def extract_datasets(inspect):
4811
+ if inspect.get("selection_required"):
4812
+ return inspect.get("data_sets") or []
4813
+ data_set = inspect.get("data_set")
4814
+ return [data_set] if data_set else []
4815
+
4816
+
4817
+ def extract_headers(inspect, datasets):
4818
+ result = {}
4819
+ details = inspect.get("header_details")
4820
+ if details:
4821
+ for dataset in datasets:
4822
+ names = (dataset.get("label"), dataset.get("id"), dataset.get("selector"))
4823
+ for sheet in details:
4824
+ if sheet.get("name") in names:
4825
+ result[dataset_key(dataset)] = sheet.get("headers") or []
4826
+ break
4827
+ return result
4828
+ columns = inspect.get("columns")
4829
+ if columns and datasets:
4830
+ result[dataset_key(datasets[0])] = [column["name"] for column in columns]
4831
+ return result
4832
+
4833
+
4834
+ def match_dataset(frozen, baseline, datasets, headers_by_dataset):
4835
+ if not datasets:
4836
+ fail("inspect reported no data sets")
4837
+ wanted = frozen["source"]["data_set"]
4838
+ for dataset in datasets:
4839
+ if dataset.get("id") == wanted:
4840
+ return dataset.get("id")
4841
+ for dataset in datasets:
4842
+ if dataset.get("label") == wanted:
4843
+ return dataset.get("id")
4844
+ baseline_cols = set(baseline.get("columns") or [])
4845
+ if baseline_cols:
4846
+ for dataset in datasets:
4847
+ headers = headers_by_dataset.get(dataset_key(dataset))
4848
+ if headers and set(headers) == baseline_cols:
4849
+ return dataset.get("id")
4850
+ if len(datasets) == 1:
4851
+ return datasets[0].get("id")
4852
+ fail(f"cannot rebind data_set {wanted!r}: no exact or header match; re-run the full pipeline")
4853
+
4854
+
4855
+ def validate_headers(data_set_id, baseline, headers_by_dataset):
4856
+ baseline_cols = set(baseline.get("columns") or [])
4857
+ if not baseline_cols:
4858
+ return
4859
+ headers = headers_by_dataset.get(data_set_id)
4860
+ if headers is None:
4861
+ return
4862
+ if set(headers) != baseline_cols:
4863
+ missing = sorted(baseline_cols - set(headers))
4864
+ extra = sorted(set(headers) - baseline_cols)
4865
+ fail(
4866
+ f"shape mismatch for {data_set_id!r}: missing={missing} extra={extra} \u2014 "
4867
+ "re-run the full pipeline; do not edit the frozen mapping"
4868
+ )
4869
+
4870
+
4871
+ if __name__ == "__main__":
4872
+ main()
4873
+ `;
4874
+ }
4875
+ function summarizePy() {
4876
+ return `#!/usr/bin/env python3
4877
+ """Transform stage summary: print valid/quarantined counts per data set."""
4878
+ import json
4879
+ import os
4880
+ import sys
4881
+
4882
+
4883
+ def pkg_root():
4884
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
4885
+
4886
+
4887
+ def main():
4888
+ if len(sys.argv) != 2:
4889
+ print("usage: summarize.py <run-dir>", file=sys.stderr)
4890
+ sys.exit(2)
4891
+ run_dir = sys.argv[1]
4892
+ root = pkg_root()
4893
+ with open(os.path.join(root, "pipeline.json"), "r", encoding="utf-8") as f:
4894
+ pipeline = json.load(f)
4895
+ total_valid = 0
4896
+ total_invalid = 0
4897
+ for ref in pipeline["transform"]["refs"]:
4898
+ ref_dir = os.path.dirname(ref)
4899
+ manifest_path = os.path.join(root, run_dir, ref_dir, "manifest.json")
4900
+ if not os.path.exists(manifest_path):
4901
+ continue
4902
+ with open(manifest_path, "r", encoding="utf-8") as f:
4903
+ manifest = json.load(f)
4904
+ output = manifest["output"]
4905
+ total_valid += output["valid_records"]
4906
+ total_invalid += output["invalid_records"]
4907
+ print(f"{ref_dir}: {output['valid_records']} valid / {output['invalid_records']} quarantined")
4908
+ for reason in manifest.get("blocked_reasons") or []:
4909
+ print(f" - {reason}")
4910
+ print(f"total: {total_valid} valid / {total_invalid} quarantined")
4911
+
4912
+
4913
+ if __name__ == "__main__":
4914
+ main()
4915
+ `;
4916
+ }
4917
+ function planCheckPy() {
4918
+ return `#!/usr/bin/env python3
4919
+ """Plan gate: every event and property produced must already exist in the frozen tracking plan."""
4920
+ import json
4921
+ import os
4922
+ import sys
4923
+
4924
+
4925
+ def pkg_root():
4926
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
4927
+
4928
+
4929
+ def load_json(path):
4930
+ with open(path, "r", encoding="utf-8") as f:
4931
+ return json.load(f)
4932
+
4933
+
4934
+ def main():
4935
+ if len(sys.argv) != 2:
4936
+ print("usage: plan_check.py <run-dir>", file=sys.stderr)
4937
+ sys.exit(2)
4938
+ run_dir = sys.argv[1]
4939
+ root = pkg_root()
4940
+ pipeline = load_json(os.path.join(root, "pipeline.json"))
4941
+ new_events = []
4942
+ new_properties = []
4943
+ missing_plans = []
4944
+ for ref in pipeline["transform"]["refs"]:
4945
+ ref_dir = os.path.dirname(ref)
4946
+ plan_path = os.path.join(root, ref_dir, "plan.json")
4947
+ ue_path = os.path.join(root, run_dir, ref_dir, "valid.ue.jsonl")
4948
+ if not os.path.exists(plan_path):
4949
+ missing_plans.append(ref_dir)
4950
+ continue
4951
+ plan = load_json(plan_path)
4952
+ plan_events = {event["event_name"] for event in plan.get("events", [])}
4953
+ plan_properties = {prop["name"] for prop in plan.get("event_properties", [])}
4954
+ plan_properties |= {prop["name"] for prop in plan.get("common_event_properties", [])}
4955
+ plan_properties |= {prop["name"] for prop in plan.get("user_properties", [])}
4956
+ produced_events = set()
4957
+ produced_properties = set()
4958
+ if os.path.exists(ue_path):
4959
+ with open(ue_path, "r", encoding="utf-8") as f:
4960
+ for line in f:
4961
+ line = line.strip()
4962
+ if not line:
4963
+ continue
4964
+ record = json.loads(line)
4965
+ if record.get("#event_name"):
4966
+ produced_events.add(record["#event_name"])
4967
+ props = record.get("properties")
4968
+ if isinstance(props, dict):
4969
+ for key in props:
4970
+ if key.startswith("#"):
4971
+ continue
4972
+ produced_properties.add(key)
4973
+ new_events.extend(sorted(produced_events - plan_events))
4974
+ new_properties.extend(sorted(produced_properties - plan_properties))
4975
+ if missing_plans:
4976
+ print("no plan.json in package for: " + ", ".join(missing_plans), file=sys.stderr)
4977
+ print("run the Tracking plan step first \u2014 the plan gate cannot be skipped", file=sys.stderr)
4978
+ sys.exit(3)
4979
+ if new_events:
4980
+ print("new events not in the plan: " + ", ".join(new_events), file=sys.stderr)
4981
+ sys.exit(3)
4982
+ if new_properties:
4983
+ print("new properties not in the plan: " + ", ".join(new_properties), file=sys.stderr)
4984
+ sys.exit(3)
4985
+ print("plan coverage ok")
4986
+
4987
+
4988
+ if __name__ == "__main__":
4989
+ main()
4990
+ `;
4991
+ }
4992
+ function verifyPy() {
4993
+ return `#!/usr/bin/env python3
4994
+ """Persistence consistency check: submit-window counts vs the platform summary.
4995
+
4996
+ A soft check, not a hard gate. It computes what this run submitted from the local
4997
+ UE output (knowable), snapshots \`ae-cli tracking ingest summary\` over the submit
4998
+ window before and after upload, and prints both next to the expected counts. It
4999
+ does NOT parse the summary payload into per-event numbers: the capability's data
5000
+ shape is server-defined and not a stable CLI contract, and a shared project cannot
5001
+ attribute the window delta to this import alone. For a hard per-event SQL judge,
5002
+ overlay a project custom layer (see custom-layer.md in the ae-data-integration skill).
5003
+
5004
+ verify.py <run-dir> --baseline snapshot the summary before upload
5005
+ verify.py <run-dir> --check snapshot again and diff against the baseline
5006
+ """
5007
+ import json
5008
+ import os
5009
+ import subprocess
5010
+ import sys
5011
+
5012
+
5013
+ def pkg_root():
5014
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5015
+
5016
+
5017
+ def load_json(path):
5018
+ with open(path, "r", encoding="utf-8") as f:
5019
+ return json.load(f)
5020
+
5021
+
5022
+ def visit_window(record, lo, hi):
5023
+ raw = record.get("#time")
5024
+ if not isinstance(raw, str) or len(raw) < 19:
5025
+ return lo, hi
5026
+ stamp = raw[:19] # YYYY-MM-DD HH:mm:ss
5027
+ if lo is None or stamp < lo:
5028
+ lo = stamp
5029
+ if hi is None or stamp > hi:
5030
+ hi = stamp
5031
+ return lo, hi
5032
+
5033
+
5034
+ def main():
5035
+ if len(sys.argv) < 2:
5036
+ print("usage: verify.py <run-dir> [--baseline|--check]", file=sys.stderr)
5037
+ sys.exit(2)
5038
+ run_dir = sys.argv[1]
5039
+ mode = sys.argv[2] if len(sys.argv) > 2 else "--check"
5040
+ if mode not in ("--baseline", "--check"):
5041
+ print("usage: verify.py <run-dir> [--baseline|--check]", file=sys.stderr)
5042
+ sys.exit(2)
5043
+ root = pkg_root()
5044
+ pipeline = load_json(os.path.join(root, "pipeline.json"))
5045
+ project_id = pipeline["sink"]["params"].get("project_id") or os.environ.get("AE_PROJECT_ID") or "1"
5046
+
5047
+ expected_events = {}
5048
+ expected_users = 0
5049
+ lo = hi = None
5050
+ for ref in pipeline["transform"]["refs"]:
5051
+ ue = os.path.join(root, run_dir, os.path.dirname(ref), "valid.ue.jsonl")
5052
+ if not os.path.exists(ue):
5053
+ continue
5054
+ with open(ue, "r", encoding="utf-8") as f:
5055
+ for line in f:
5056
+ line = line.strip()
5057
+ if not line:
5058
+ continue
5059
+ record = json.loads(line)
5060
+ if record.get("#type") == "track" and record.get("#event_name"):
5061
+ expected_events[record["#event_name"]] = expected_events.get(record["#event_name"], 0) + 1
5062
+ else:
5063
+ expected_users += 1
5064
+ lo, hi = visit_window(record, lo, hi)
5065
+
5066
+ if lo is None:
5067
+ print("verify: no UE records found in " + run_dir, file=sys.stderr)
5068
+ sys.exit(2)
5069
+
5070
+ def run_summary():
5071
+ proc = subprocess.run(
5072
+ ["ae-cli", "tracking", "ingest", "summary",
5073
+ "-p", str(project_id), "--start-time", lo, "--end-time", hi],
5074
+ capture_output=True, text=True,
5075
+ )
5076
+ if proc.returncode != 0:
5077
+ return {"error": (proc.stderr or proc.stdout).strip()[:500]}
5078
+ try:
5079
+ return json.loads(proc.stdout)
5080
+ except json.JSONDecodeError:
5081
+ return {"raw": proc.stdout.strip()[:500]}
5082
+
5083
+ print("window " + lo + " .. " + hi + " project_id=" + str(project_id))
5084
+ print("expected (this run):")
5085
+ for event, count in sorted(expected_events.items()):
5086
+ print(" {:<20}{:>10,}".format(event, count))
5087
+ print(" {:<20}{:>10,}".format("<user rows>", expected_users))
5088
+ print()
5089
+
5090
+ baseline_path = os.path.join(root, run_dir, "baseline.json")
5091
+ if mode == "--baseline":
5092
+ payload = run_summary()
5093
+ with open(baseline_path, "w", encoding="utf-8") as f:
5094
+ json.dump(payload, f, ensure_ascii=False, indent=2)
5095
+ print("baseline recorded: " + baseline_path)
5096
+ print("platform summary (before):")
5097
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
5098
+ sys.exit(0)
5099
+
5100
+ if not os.path.exists(baseline_path):
5101
+ print("no baseline.json \u2014 run \`verify.py <run-dir> --baseline\` before upload.", file=sys.stderr)
5102
+ print("falling back to a single after-upload summary:", file=sys.stderr)
5103
+ print(json.dumps(run_summary(), ensure_ascii=False, indent=2))
5104
+ sys.exit(1)
5105
+
5106
+ with open(baseline_path, "r", encoding="utf-8") as f:
5107
+ baseline = json.load(f)
5108
+ after = run_summary()
5109
+ print("platform summary before vs after:")
5110
+ print(json.dumps(baseline, ensure_ascii=False, indent=2))
5111
+ print("---")
5112
+ print(json.dumps(after, ensure_ascii=False, indent=2))
5113
+ print()
5114
+ print("summary changed: " + ("yes" if baseline != after else "no"))
5115
+ print()
5116
+ print("Boundary: the summary payload is server-defined and may under-report even")
5117
+ print("landed data; this check surfaces it for comparison, it does not auto-verify")
5118
+ print("per-event counts, and a shared project's window delta is not attributed to")
5119
+ print("this import. Cross-check with:")
5120
+ print(" ae-cli tracking live-data list -p " + str(project_id))
5121
+ print("For a hard SQL judge, add a project custom layer (custom-layer.md).")
5122
+ sys.exit(0)
5123
+
5124
+
5125
+ if __name__ == "__main__":
5126
+ main()
5127
+ `;
5128
+ }
5129
+ function resolveAppidPy() {
5130
+ return `#!/usr/bin/env python3
5131
+ """Derive the destination APPID from \`ae-cli project info get --project-id <id>\`.
5132
+
5133
+ \`project info get\` returns \`data.appid\` at the top level (verified against the
5134
+ AE demo host), so this helper reads that exact field and prints it to stdout. It
5135
+ prints nothing to stdout and reports the payload when the field is absent or not
5136
+ a non-empty string \u2014 the caller then falls back to AE_APPID.
5137
+
5138
+ Usage: bin/resolve_appid.py <project-id>
5139
+ """
5140
+ import json
5141
+ import subprocess
5142
+ import sys
5143
+
5144
+
5145
+ def main():
5146
+ if len(sys.argv) != 2:
5147
+ print("usage: resolve_appid.py <project-id>", file=sys.stderr)
5148
+ sys.exit(2)
5149
+ project_id = sys.argv[1]
5150
+ proc = subprocess.run(
5151
+ ["ae-cli", "project", "info", "get", "--project-id", project_id],
5152
+ capture_output=True, text=True,
5153
+ )
5154
+ if proc.returncode != 0:
5155
+ print("resolve_appid: project info get failed: " + (proc.stderr or proc.stdout).strip()[:300], file=sys.stderr)
5156
+ sys.exit(0)
5157
+ try:
5158
+ parsed = json.loads(proc.stdout)
5159
+ except json.JSONDecodeError:
5160
+ print("resolve_appid: project info get returned non-JSON output", file=sys.stderr)
5161
+ sys.exit(0)
5162
+ data = parsed.get("data") if isinstance(parsed, dict) else None
5163
+ appid = data.get("appid") if isinstance(data, dict) else None
5164
+ if not isinstance(appid, str) or not appid:
5165
+ print("resolve_appid: project info get returned no appid; set AE_APPID", file=sys.stderr)
5166
+ print(json.dumps(data, ensure_ascii=False, indent=2) if data is not None else "{}", file=sys.stderr)
5167
+ sys.exit(0)
5168
+ masked = appid if len(appid) <= 4 else ("*" * (len(appid) - 4)) + appid[-4:]
5169
+ print("resolve_appid: resolved APPID data.appid = " + masked, file=sys.stderr)
5170
+ print(appid)
5171
+
5172
+
5173
+ if __name__ == "__main__":
5174
+ main()
5175
+ `;
5176
+ }
5177
+ function generateReadme() {
5178
+ return `# AE Data Integration \u2014 handoff package
5179
+
5180
+ A frozen, reusable pipeline for importing a **same-shape** local file into AE,
5181
+ generated by \`ae-cli data-integration handoff\`. Source and Transform are frozen
5182
+ (the confirmed business logic); only the Tracking-plan and Sink gates still
5183
+ require human confirmation.
5184
+
5185
+ ## Quick start
5186
+
5187
+ \`\`\`bash
5188
+ cp <today's file> inbox/
5189
+ bin/run.sh # shape check -> convert -> plan check (no upload)
5190
+ bin/upload.sh runs/<latest> # dry-run
5191
+ bin/upload.sh runs/<latest> --confirm
5192
+ \`\`\`
5193
+
5194
+ Read [RUNBOOK.md](RUNBOOK.md) for the full flow, the four confirmation gates,
5195
+ and how to verify persistence.
5196
+
5197
+ ## Handing off to an agent
5198
+
5199
+ Import \`inbox/<today's file>\` through the ae-data-integration skill using this
5200
+ package. Tell it to follow RUNBOOK.md and stop for confirmation before uploading.
5201
+
5202
+ ## Package layout
5203
+
5204
+ | Path | Purpose |
5205
+ | --- | --- |
5206
+ | \`pipeline.json\` | Declarative source -> transform -> sink descriptor |
5207
+ | \`index.json\` | Structure-fingerprint index (reuse matching) |
5208
+ | \`shape.json\` | Column baseline used by the shape gate |
5209
+ | \`<fingerprint16>/\` | Frozen mapping + tracking plan + transform wrapper |
5210
+ | \`bin/run.sh\` | Source + Transform + Plan executor (never uploads) |
5211
+ | \`bin/upload.sh\` | Sink executor (dry-run by default; resolves the recorded target) |
5212
+ | \`bin/verify.py\` | Soft persistence check (submit window vs ingest summary) |
5213
+ | \`bin/resolve_appid.py\` | APPID derivation helper (project info get) |
5214
+ | \`.local/target.env\` | Upload target overrides (APPID / endpoint); only the template ships |
5215
+ | \`inbox/\` \`runs/\` | Daily input / per-run outputs |
5216
+
5217
+ ## Safety
5218
+
5219
+ The package records at most a destination \`pushurl\` and \`project_id\` (no APPID,
5220
+ tokens, or raw data values). \`bin/upload.sh\` always requires \`--confirm\` before
5221
+ sending, so the operator re-confirms the address and project on every reuse. Copy
5222
+ \`.local/target.env.example\` to \`.local/target.env\` for explicit overrides and
5223
+ never commit it.
5224
+ `;
5225
+ }
5226
+ function generateRunbook() {
5227
+ return `# RUNBOOK \u2014 same-shape file import
5228
+
5229
+ Run this when a file of the **same shape** arrives again (same sheets and
5230
+ headers as \`shape.json\`). If the headers changed, stop: re-run the full
5231
+ ae-data-integration pipeline instead of editing the frozen mapping.
5232
+
5233
+ ## Gates
5234
+
5235
+ 1. **Shape gate** \u2014 \`bin/run.sh\` rebinds the frozen mappings to the new file
5236
+ and compares the column set against \`shape.json\`. A mismatch fails fast on
5237
+ purpose: a different shape means the frozen business logic was never reviewed
5238
+ for it.
5239
+ 2. **Transform** \u2014 each frozen mapping runs through
5240
+ \`ae-cli data-integration convert\`. Quarantined rows land in
5241
+ \`invalid.rows.jsonl\`; they are never silently dropped.
5242
+ 3. **Tracking-plan gate** \u2014 \`bin/run.sh\` verifies every produced event and
5243
+ property already exists in the frozen \`plan.json\`. New events or properties
5244
+ make it exit with code 3: merge them into the project tracking plan first,
5245
+ then return here to upload.
5246
+ 4. **Sink gate** \u2014 \`bin/upload.sh\` is dry-run by default. Read
5247
+ \`record_count\`, \`batch_count\`, and \`manifest_status\` before adding
5248
+ \`--confirm\`. A \`blocked\` manifest means rows were quarantined;
5249
+ \`--confirm\` then uploads only the valid subset (\`--allow-clean-subset\`).
5250
+
5251
+ ## Destination
5252
+
5253
+ The package records the destination it was handed off for when \`handoff\` was run
5254
+ with \`--pushurl\` / \`--project-id\` (see \`pipeline.json\` \u2192 \`sink.params\`). Reuse
5255
+ defaults to that target, but \`bin/upload.sh\` never sends without \`--confirm\`, so
5256
+ the operator re-confirms the address and project every time.
5257
+
5258
+ Resolution order at upload time:
5259
+
5260
+ - endpoint: recorded \`pushurl\` (+ \`/sync_json\`), else \`AE_ENDPOINT\`.
5261
+ - APPID: \`AE_APPID\`, else derived via \`ae-cli project info get --project-id <id>\`
5262
+ (see \`bin/resolve_appid.py\`; it reads the \`data.appid\` field \u2014 set \`AE_APPID\`
5263
+ when that field is absent).
5264
+ - project id: recorded \`project_id\`, else \`AE_PROJECT_ID\`.
5265
+
5266
+ \`.local/target.env\` remains the explicit override for all three:
5267
+ \`AE_ENDPOINT\` (full receiver URL ending in \`/sync_json\`), \`AE_APPID\`, \`AE_PROJECT_ID\`.
5268
+
5269
+ ## Verify persistence
5270
+
5271
+ \`receiver_accepted\` is not persistence. About a minute after upload, confirm
5272
+ the data landed with ae-cli. The package ships a soft check that automates the
5273
+ before/after comparison:
5274
+
5275
+ \`\`\`bash
5276
+ bin/verify.py runs/<run-id> --baseline # before upload
5277
+ bin/upload.sh runs/<run-id> --confirm
5278
+ bin/verify.py runs/<run-id> --check # after upload
5279
+ \`\`\`
5280
+
5281
+ \`bin/verify.py\` prints the submit window and expected counts, then shows the
5282
+ \`tracking ingest summary\` payload before and after for comparison. It does not
5283
+ auto-verify per-event counts \u2014 the summary shape is server-defined, and a shared
5284
+ project's window delta is not attributed to this import. Cross-check with:
5285
+
5286
+ \`\`\`bash
5287
+ ae-cli tracking live-data list -p <AE_PROJECT_ID>
5288
+ ae-cli tracking ingest-error list -p <AE_PROJECT_ID> --data-name <name>
5289
+ \`\`\`
5290
+
5291
+ For a hard per-event SQL judge, overlay a project custom layer instead of editing
5292
+ this package (see \`custom-layer.md\` in the ae-data-integration skill).
5293
+
5294
+ ## Interrupted uploads
5295
+
5296
+ If a batch times out or loses the network, that batch's state is unknown. Stop:
5297
+ verify what actually landed, then follow the ae-data-integration skill to resume
5298
+ from the verified offset. Never re-run the whole upload blindly.
5299
+
5300
+ ## Files
5301
+
5302
+ See \`README.md\` for the package layout.
5303
+ `;
5304
+ }
5305
+ function generateEnvTemplate() {
5306
+ return [
5307
+ "# Upload target overrides. Copy this file to .local/target.env and fill only",
5308
+ "# what the package does not already record (pipeline.json sink.params).",
5309
+ "# AE_ENDPOINT: a full receiver URL ending in /sync_json (used when no pushurl is recorded).",
5310
+ "# AE_APPID: the destination project APPID (overrides the project info get derivation).",
5311
+ "# AE_PROJECT_ID: the destination project ID, used when no project_id is recorded.",
5312
+ "AE_ENDPOINT=",
5313
+ "AE_APPID=",
5314
+ "AE_PROJECT_ID=",
5315
+ ""
5316
+ ].join("\n");
5317
+ }
5318
+ function generateGitignore() {
5319
+ return ["inbox/", "runs/", ".local/target.env", ""].join("\n");
5320
+ }
5321
+
5322
+ // src/commands/data-integration/handoff.ts
3243
5323
  var HANDOFF_INDEX_VERSION = "ae-data-integration-index/v1";
3244
5324
  var HANDOFF_DIR_LEN = 16;
3245
5325
  function structureFingerprint(mapping) {
3246
5326
  const canonical = {
3247
5327
  mode: mapping.mode,
3248
- time_field: mapping.time.field,
3249
- account_id_field: mapping.account_id_field ?? null,
3250
- distinct_id_field: mapping.distinct_id_field ?? null,
3251
- record_type_field: mapping.record_type_field ?? null,
3252
- event_name_field: mapping.event_name_field ?? null,
3253
- columns: mapping.properties.map((property) => ({ source: property.source, type: property.type })).sort((left, right) => left.source.localeCompare(right.source)),
3254
- excluded: [...mapping.exclude_columns ?? []].sort()
5328
+ format: mapping.source.format,
5329
+ columns: sourceColumns(mapping)
3255
5330
  };
3256
5331
  return createHash3("sha256").update(JSON.stringify(canonical)).digest("hex");
3257
5332
  }
@@ -3262,17 +5337,17 @@ function upsertIndexEntry(index, entry) {
3262
5337
  function buildHandoffPackage(outDir, mapping, planFile) {
3263
5338
  const fingerprint = structureFingerprint(mapping);
3264
5339
  const dirName = fingerprint.slice(0, HANDOFF_DIR_LEN);
3265
- const handoffDir = join2(outDir, dirName);
3266
- const indexPath = join2(outDir, "index.json");
5340
+ const handoffDir = join3(outDir, dirName);
5341
+ const indexPath = join3(outDir, "index.json");
3267
5342
  const index = readHandoffIndex(indexPath);
3268
5343
  const reusedExisting = index.entries.some((item) => item.fingerprint === fingerprint);
3269
5344
  mkdirSync2(handoffDir, { recursive: true, mode: 448 });
3270
5345
  chmodSync2(handoffDir, 448);
3271
- writeSecureJson2(join2(handoffDir, "mapping.json"), mapping);
3272
- writeSecureText2(join2(handoffDir, "transform.mjs"), createHandoffScript());
5346
+ writeSecureJson2(join3(handoffDir, "mapping.json"), mapping);
5347
+ writeSecureText2(join3(handoffDir, "transform.mjs"), createHandoffScript());
3273
5348
  let planFileRel;
3274
5349
  if (planFile) {
3275
- writeSecureJson2(join2(handoffDir, "plan.json"), readPlanFile(planFile));
5350
+ writeSecureJson2(join3(handoffDir, "plan.json"), readPlanFile(planFile));
3276
5351
  planFileRel = `${dirName}/plan.json`;
3277
5352
  }
3278
5353
  const entry = {
@@ -3287,7 +5362,7 @@ function buildHandoffPackage(outDir, mapping, planFile) {
3287
5362
  ...planFileRel ? { plan_file: planFileRel } : {}
3288
5363
  };
3289
5364
  writeAtomicJson(indexPath, upsertIndexEntry(index, entry));
3290
- return { outDir, handoffDir, dirName, fingerprint, reusedExisting, planFile: planFileRel };
5365
+ return { outDir, handoffDir, dirName, fingerprint, reusedExisting, planFile: planFileRel, entry };
3291
5366
  }
3292
5367
  function readHandoffIndex(path) {
3293
5368
  let raw;
@@ -3382,56 +5457,199 @@ var dataIntegrationHandoff = {
3382
5457
  service: "data-integration",
3383
5458
  command: "handoff",
3384
5459
  usesAeHost: false,
3385
- description: "Export a reusable handoff package (frozen mapping + transform script + plan reference) under .ae-data-integration/.",
5460
+ description: "Export a reusable handoff package (pipeline descriptor + frozen mappings + stage executors + docs) and a shareable zip.",
3386
5461
  flags: [
3387
- { name: "mapping", type: "string", required: true, sensitive: true, desc: "Confirmed ae-local-data-mapping/v1 JSON, file path, or @file." },
5462
+ { name: "mapping", type: "string", required: true, variadic: true, sensitive: true, desc: `Confirmed ${MAPPING_VERSION} JSON, file path, or @file. Repeat for multiple data sets (one sheet each).` },
3388
5463
  { name: "plan-file", type: "string", sensitive: true, desc: "Tracking-plan draft.json to reference inside the handoff package." },
3389
- { name: "out-dir", type: "string", sensitive: true, desc: "Handoff root directory. Default: .ae-data-integration." }
5464
+ { name: "out-dir", type: "string", sensitive: true, desc: "Handoff root directory. Default: .ae-cli/data-integration (project workspace)." },
5465
+ { name: "pushurl", type: "string", sensitive: true, desc: "Receiver base URL to record as the reuse upload target (sink endpoint = pushurl + /sync_json). Reuse still requires --confirm." },
5466
+ { name: "project-id", type: "string", sensitive: true, desc: "Numeric destination project ID to record; upload derives the APPID from it via project info get." }
3390
5467
  ],
3391
5468
  risk: "write",
3392
5469
  dryRun: async (ctx) => {
3393
- const mapping = readLocalDataMapping(ctx.str("mapping"));
3394
- const outDir = resolve3(ctx.str("out-dir").trim() || ".ae-data-integration");
5470
+ const mappings = ctx.list("mapping").map((source) => readLocalDataMapping(source));
5471
+ const outDir = resolve3(ctx.str("out-dir").trim() || join3(".ae-cli", "data-integration"));
3395
5472
  const planFile = ctx.str("plan-file").trim() || void 0;
5473
+ const pushurl = ctx.str("pushurl").trim() || void 0;
5474
+ const projectId = ctx.str("project-id").trim() || void 0;
5475
+ const fingerprints = mappings.map(structureFingerprint);
3396
5476
  return {
3397
5477
  action: "handoff_local_data",
3398
5478
  out_dir: outDir,
3399
- fingerprint: structureFingerprint(mapping),
3400
- source_sha256: mapping.source.sha256,
3401
- mode: mapping.mode,
3402
- property_count: mapping.properties.length,
3403
- files: ["mapping.json", "transform.mjs", ...planFile ? ["plan.json"] : []],
3404
- index_file: join2(outDir, "index.json")
5479
+ mapping_count: mappings.length,
5480
+ fingerprints,
5481
+ target: { pushurl, project_id: projectId },
5482
+ files: handoffFileList(fingerprints, planFile),
5483
+ index_file: join3(outDir, "index.json"),
5484
+ pipeline_file: join3(outDir, "pipeline.json"),
5485
+ shape_file: join3(outDir, "shape.json"),
5486
+ zip_path: zipPathFor(outDir, fingerprints[0])
3405
5487
  };
3406
5488
  },
3407
5489
  execute: async (ctx) => {
3408
- const mapping = readLocalDataMapping(ctx.str("mapping"));
3409
- const outDir = resolve3(ctx.str("out-dir").trim() || ".ae-data-integration");
5490
+ const mappings = ctx.list("mapping").map((source) => readLocalDataMapping(source));
5491
+ const outDir = resolve3(ctx.str("out-dir").trim() || join3(".ae-cli", "data-integration"));
3410
5492
  const planFile = ctx.str("plan-file").trim() || void 0;
3411
- const build = buildHandoffPackage(outDir, mapping, planFile);
5493
+ const target = {
5494
+ pushurl: ctx.str("pushurl").trim() || void 0,
5495
+ project_id: ctx.str("project-id").trim() || void 0
5496
+ };
5497
+ const items = mappings.map((mapping) => ({
5498
+ mapping,
5499
+ build: buildHandoffPackage(outDir, mapping, planFile)
5500
+ }));
5501
+ const entries = items.map((item) => item.build.entry);
5502
+ const relayFiles = [
5503
+ { relPath: "pipeline.json", content: `${JSON.stringify(buildPipelineDescriptor(entries, target), null, 2)}
5504
+ `, mode: 384 },
5505
+ { relPath: "shape.json", content: `${JSON.stringify(buildShapeBaseline(items.map((item) => ({ mapping: item.mapping, fingerprint: item.build.fingerprint }))), null, 2)}
5506
+ `, mode: 384 },
5507
+ ...generateBinScripts(),
5508
+ { relPath: "README.md", content: generateReadme(), mode: 384 },
5509
+ { relPath: "RUNBOOK.md", content: generateRunbook(), mode: 384 },
5510
+ { relPath: ".local/target.env.example", content: generateEnvTemplate(), mode: 384 },
5511
+ { relPath: ".gitignore", content: generateGitignore(), mode: 384 },
5512
+ { relPath: "inbox/.gitkeep", content: "", mode: 384 },
5513
+ { relPath: "runs/.gitkeep", content: "", mode: 384 }
5514
+ ];
5515
+ for (const file of relayFiles) writeRelayFile(outDir, file);
5516
+ const zipPath = zipPathFor(outDir, entries[0].fingerprint);
5517
+ const stagingDir = stageScopedPackage(outDir, items.map((item) => item.build), entries, relayFiles);
5518
+ try {
5519
+ await zipPackage(stagingDir, zipPath);
5520
+ chmodSync2(zipPath, 384);
5521
+ } finally {
5522
+ rmSync(stagingDir, { recursive: true, force: true });
5523
+ }
3412
5524
  return {
3413
- out_dir: build.outDir,
3414
- handoff_dir: build.handoffDir,
3415
- fingerprint: build.fingerprint,
3416
- mapping_file: `${build.dirName}/mapping.json`,
3417
- ...build.planFile ? { plan_file: build.planFile } : {},
3418
- run: `node ${join2(build.handoffDir, "transform.mjs")} <new-input-file> [<output-dir>]`,
3419
- index_file: join2(outDir, "index.json"),
3420
- reused_existing: build.reusedExisting
5525
+ out_dir: outDir,
5526
+ zip_path: zipPath,
5527
+ pipeline_file: join3(outDir, "pipeline.json"),
5528
+ shape_file: join3(outDir, "shape.json"),
5529
+ index_file: join3(outDir, "index.json"),
5530
+ handoff_dirs: items.map((item) => item.build.dirName),
5531
+ deliverables: buildDeliverables(outDir, relayFiles, items),
5532
+ next_steps: [
5533
+ `Review the pipeline descriptor and four confirmation gates: ${join3(outDir, "RUNBOOK.md")}.`,
5534
+ `Share the archive: ${zipPath}.`,
5535
+ `Next same-shape file: cd ${outDir} && bin/run.sh <new-file>, then bin/upload.sh runs/<run-id> --confirm.`
5536
+ ],
5537
+ reused_existing: items.some((item) => item.build.reusedExisting)
3421
5538
  };
3422
5539
  }
3423
5540
  };
5541
+ function zipPathFor(outDir, fingerprint) {
5542
+ if (!fingerprint) return void 0;
5543
+ return join3(dirname2(resolve3(outDir)), `ae-data-integration-handoff-${fingerprint.slice(0, 8)}.zip`);
5544
+ }
5545
+ var RELAY_FILE_PATHS = [
5546
+ "pipeline.json",
5547
+ "shape.json",
5548
+ "index.json",
5549
+ "README.md",
5550
+ "RUNBOOK.md",
5551
+ ".local/target.env.example",
5552
+ ".gitignore",
5553
+ "bin/run.sh",
5554
+ "bin/upload.sh",
5555
+ "bin/bind_mapping.py",
5556
+ "bin/summarize.py",
5557
+ "bin/plan_check.py",
5558
+ "bin/verify.py",
5559
+ "bin/resolve_appid.py",
5560
+ "inbox/.gitkeep",
5561
+ "runs/.gitkeep"
5562
+ ];
5563
+ function mappingDirFiles(dirName, planFile) {
5564
+ return [
5565
+ `${dirName}/mapping.json`,
5566
+ `${dirName}/transform.mjs`,
5567
+ ...planFile ? [`${dirName}/plan.json`] : []
5568
+ ];
5569
+ }
5570
+ function handoffFileList(fingerprints, planFile) {
5571
+ const perMapping = fingerprints.flatMap(
5572
+ (fingerprint) => mappingDirFiles(fingerprint.slice(0, HANDOFF_DIR_LEN), planFile)
5573
+ );
5574
+ return [...RELAY_FILE_PATHS, ...perMapping];
5575
+ }
5576
+ function buildDeliverables(outDir, relayFiles, items) {
5577
+ const relay = [
5578
+ { rel_path: "index.json", abs_path: join3(outDir, "index.json") },
5579
+ ...relayFiles.map((file) => ({ rel_path: file.relPath, abs_path: join3(outDir, file.relPath) }))
5580
+ ];
5581
+ const mappings = items.flatMap(
5582
+ (item) => mappingDirFiles(item.build.dirName, item.build.planFile).map((rel) => ({
5583
+ rel_path: rel,
5584
+ abs_path: join3(outDir, rel)
5585
+ }))
5586
+ );
5587
+ return [...relay, ...mappings];
5588
+ }
5589
+ function writeRelayFile(outDir, file) {
5590
+ const abs = join3(outDir, file.relPath);
5591
+ mkdirSync2(dirname2(abs), { recursive: true, mode: 448 });
5592
+ writeFileSync2(abs, file.content, { encoding: "utf8", mode: file.mode });
5593
+ chmodSync2(abs, file.mode);
5594
+ }
5595
+ function stageScopedPackage(outDir, mappingDirs, entries, relayFiles) {
5596
+ const staging = mkdtempSync(join3(tmpdir(), "ae-handoff-"));
5597
+ writeSecureJson2(join3(staging, "index.json"), { version: HANDOFF_INDEX_VERSION, entries });
5598
+ for (const file of relayFiles) writeRelayFile(staging, file);
5599
+ for (const { dirName, planFile } of mappingDirs) {
5600
+ for (const rel of mappingDirFiles(dirName, planFile)) {
5601
+ const dest = join3(staging, rel);
5602
+ mkdirSync2(dirname2(dest), { recursive: true, mode: 448 });
5603
+ writeFileSync2(dest, readFileSync4(join3(outDir, rel), "utf8"), { encoding: "utf8", mode: 384 });
5604
+ chmodSync2(dest, 384);
5605
+ }
5606
+ }
5607
+ return staging;
5608
+ }
3424
5609
 
3425
- // src/commands/data-integration/local-data/reuse.ts
5610
+ // src/commands/data-integration/reuse.ts
3426
5611
  import { readFileSync as readFileSync5 } from "fs";
3427
- import { dirname as dirname2, join as join3, resolve as resolve4 } from "path";
5612
+ import { dirname as dirname4, join as join5, resolve as resolve5 } from "path";
5613
+
5614
+ // src/commands/data-integration/handoff-root.ts
5615
+ import { existsSync as existsSync2 } from "fs";
5616
+ import { dirname as dirname3, join as join4, resolve as resolve4 } from "path";
5617
+ function globalHandoffDir() {
5618
+ const home = process.env.HOME;
5619
+ if (!home) return void 0;
5620
+ return join4(getConfigDir(), "data-integration");
5621
+ }
5622
+ function upwardHandoffDirs(startDir) {
5623
+ const dirs = [];
5624
+ let current = resolve4(startDir);
5625
+ for (; ; ) {
5626
+ const candidate = join4(current, ".ae-cli", "data-integration");
5627
+ if (!dirs.includes(candidate)) dirs.push(candidate);
5628
+ const parent = dirname3(current);
5629
+ if (parent === current) break;
5630
+ current = parent;
5631
+ }
5632
+ return dirs;
5633
+ }
5634
+ function reuseSearchPaths(startDir = process.cwd()) {
5635
+ const globalDir = globalHandoffDir();
5636
+ return globalDir ? [...upwardHandoffDirs(startDir), globalDir] : upwardHandoffDirs(startDir);
5637
+ }
5638
+ function findReuseRoot(startDir = process.cwd()) {
5639
+ for (const dir of upwardHandoffDirs(startDir)) {
5640
+ if (existsSync2(join4(dir, "index.json"))) return dir;
5641
+ }
5642
+ return globalHandoffDir();
5643
+ }
5644
+
5645
+ // src/commands/data-integration/reuse.ts
3428
5646
  function detectReuse(mapping, outDir) {
3429
5647
  const fingerprint = structureFingerprint(mapping);
3430
- const indexPath = join3(outDir, "index.json");
5648
+ const indexPath = join5(outDir, "index.json");
3431
5649
  const index = readHandoffIndex(indexPath);
3432
5650
  const entry = index.entries.find((item) => item.fingerprint === fingerprint);
3433
5651
  if (!entry) return { matched: false, fingerprint, index_file: indexPath };
3434
- const mappingPath = join3(outDir, entry.mapping_file);
5652
+ const mappingPath = join5(outDir, entry.mapping_file);
3435
5653
  const match = {
3436
5654
  fingerprint: entry.fingerprint,
3437
5655
  created_at: entry.created_at,
@@ -3442,7 +5660,7 @@ function detectReuse(mapping, outDir) {
3442
5660
  mapping_file: entry.mapping_file,
3443
5661
  ...entry.plan_file ? { plan_file: entry.plan_file } : {},
3444
5662
  ...readFrozenEventName(mappingPath),
3445
- run: `node ${join3(outDir, dirname2(entry.mapping_file), "transform.mjs")} <new-input-file> [<output-dir>]`
5663
+ run: `node ${join5(outDir, dirname4(entry.mapping_file), "transform.mjs")} <new-input-file> [<output-dir>]`
3446
5664
  };
3447
5665
  return { matched: true, fingerprint, index_file: indexPath, match };
3448
5666
  }
@@ -3465,29 +5683,41 @@ var dataIntegrationReuse = {
3465
5683
  service: "data-integration",
3466
5684
  command: "reuse",
3467
5685
  usesAeHost: false,
3468
- description: "Match a candidate mapping against the .ae-data-integration/ handoff index and propose a reusable package.",
5686
+ description: "Match a candidate mapping against the .ae-cli/data-integration/ handoff index and propose a reusable package.",
3469
5687
  flags: [
3470
- { name: "mapping", type: "string", required: true, sensitive: true, desc: "Candidate ae-local-data-mapping/v1 JSON, file path, or @file (typically inspect recommended_mapping)." },
3471
- { name: "out-dir", type: "string", sensitive: true, desc: "Handoff root directory. Default: .ae-data-integration." }
5688
+ { name: "mapping", type: "string", required: true, sensitive: true, desc: `Candidate ${MAPPING_VERSION} JSON, file path, or @file (typically inspect recommended_mapping).` },
5689
+ { name: "out-dir", type: "string", sensitive: true, desc: "Handoff root directory. Default: nearest .ae-cli/data-integration/ upward from cwd, then ~/.ae-cli/data-integration/." }
3472
5690
  ],
3473
5691
  risk: "read",
3474
5692
  dryRun: async (ctx) => {
3475
5693
  const mapping = readLocalDataMapping(ctx.str("mapping"));
3476
- const outDir = resolve4(ctx.str("out-dir").trim() || ".ae-data-integration");
5694
+ const outDir = resolveReuseOutDir(ctx);
3477
5695
  const result = detectReuse(mapping, outDir);
3478
5696
  return {
3479
5697
  action: "reuse_detect",
3480
5698
  fingerprint: result.fingerprint,
3481
5699
  matched: result.matched,
3482
- index_file: result.index_file
5700
+ index_file: result.index_file,
5701
+ searched_paths: searchedIndexPaths(ctx)
3483
5702
  };
3484
5703
  },
3485
5704
  execute: async (ctx) => {
3486
5705
  const mapping = readLocalDataMapping(ctx.str("mapping"));
3487
- const outDir = resolve4(ctx.str("out-dir").trim() || ".ae-data-integration");
3488
- return detectReuse(mapping, outDir);
5706
+ const outDir = resolveReuseOutDir(ctx);
5707
+ const result = detectReuse(mapping, outDir);
5708
+ return { ...result, searched_paths: searchedIndexPaths(ctx) };
3489
5709
  }
3490
5710
  };
5711
+ function resolveReuseOutDir(ctx) {
5712
+ const explicit = ctx.str("out-dir").trim();
5713
+ if (explicit) return resolve5(explicit);
5714
+ return findReuseRoot() ?? resolve5(join5(".ae-cli", "data-integration"));
5715
+ }
5716
+ function searchedIndexPaths(ctx) {
5717
+ const explicit = ctx.str("out-dir").trim();
5718
+ const dirs = explicit ? [resolve5(explicit)] : reuseSearchPaths();
5719
+ return dirs.map((dir) => join5(dir, "index.json"));
5720
+ }
3491
5721
 
3492
5722
  // src/commands/data-integration/index.ts
3493
5723
  var commands = [dataIntegrationInspect, dataIntegrationPlan, dataIntegrationConvert, dataIntegrationUpload, dataIntegrationHandoff, dataIntegrationReuse];