onto-mcp 0.4.12 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.onto/authority/core-lexicon.yaml +1 -1
  2. package/.onto/authority/supported-models.yaml +137 -0
  3. package/.onto/domains/accounting-kr/competency_qs.md +153 -153
  4. package/.onto/processes/reconstruct/obligation-coverage-ledger.yaml +842 -0
  5. package/.onto/processes/reconstruct/obligation-coverage-recorded.yaml +225 -0
  6. package/.onto/processes/reconstruct/ontology-seeding-and-maturation-design.md +93 -54
  7. package/.onto/processes/reconstruct/reconstruct-contract-registry.yaml +169 -8
  8. package/.onto/processes/reconstruct/reconstruct-execution-ux-contract.md +72 -1
  9. package/.onto/processes/reconstruct/source-profiles/spreadsheet.md +35 -0
  10. package/.onto/processes/review/productized-live-path.md +9 -5
  11. package/.onto/processes/review/prompt-execution-runner-contract.md +32 -8
  12. package/.onto/processes/review/review-target-profile-contract.md +47 -4
  13. package/.onto/processes/shared/pipeline-execution-ledger-contract.md +11 -0
  14. package/.onto/processes/shared/target-material-kind-contract.md +1 -1
  15. package/README.md +64 -1
  16. package/dist/cli.js +11 -1
  17. package/dist/core-api/reconstruct-api.js +575 -24
  18. package/dist/core-api/review-api.js +70 -5
  19. package/dist/core-api/review-progress.js +7 -0
  20. package/dist/core-api/runtime-observation.js +7 -0
  21. package/dist/core-runtime/artifact-io.js +75 -3
  22. package/dist/core-runtime/cli/claude-code-review-unit-executor.js +19 -8
  23. package/dist/core-runtime/cli/materialize-review-prompt-packets.js +68 -14
  24. package/dist/core-runtime/cli/prepare-review-session.js +34 -1
  25. package/dist/core-runtime/cli/render-review-final-output.js +2 -1
  26. package/dist/core-runtime/cli/run-review-prompt-execution.js +758 -48
  27. package/dist/core-runtime/cli/run-seats.js +18 -0
  28. package/dist/core-runtime/cli/runtime-submit-context.js +10 -2
  29. package/dist/core-runtime/cli/unit-resubmit.js +269 -0
  30. package/dist/core-runtime/cli/worker-structured-output.js +80 -2
  31. package/dist/core-runtime/discovery/host-detection.js +71 -22
  32. package/dist/core-runtime/discovery/review-cert-assemble.js +208 -0
  33. package/dist/core-runtime/discovery/review-cert-record.js +442 -0
  34. package/dist/core-runtime/discovery/review-execution-units.js +22 -0
  35. package/dist/core-runtime/discovery/seat-inventory.js +167 -0
  36. package/dist/core-runtime/discovery/settings-chain.js +311 -52
  37. package/dist/core-runtime/discovery/supported-models.js +282 -29
  38. package/dist/core-runtime/discovery/synthesize-cert-assemble.js +197 -0
  39. package/dist/core-runtime/discovery/synthesize-cert-capsule.js +399 -0
  40. package/dist/core-runtime/discovery/synthesize-cert-judge.js +340 -0
  41. package/dist/core-runtime/discovery/synthesize-cert-loop.js +234 -0
  42. package/dist/core-runtime/discovery/synthesize-cert-mutation.js +117 -0
  43. package/dist/core-runtime/discovery/synthesize-cert-packet.js +182 -0
  44. package/dist/core-runtime/discovery/synthesize-cert-record.js +831 -0
  45. package/dist/core-runtime/discovery/synthesize-cert-sampler.js +336 -0
  46. package/dist/core-runtime/effort-calibration-ingest.js +135 -0
  47. package/dist/core-runtime/llm/dispatch-breaker.js +412 -0
  48. package/dist/core-runtime/llm/dispatch-fallback-adapter-capabilities.js +33 -0
  49. package/dist/core-runtime/llm/llm-caller.js +188 -47
  50. package/dist/core-runtime/llm/mock-llm-realization.js +35 -4
  51. package/dist/core-runtime/llm/model-switcher.js +1 -1
  52. package/dist/core-runtime/llm/openai-responses-incomplete-error.js +46 -0
  53. package/dist/core-runtime/llm/sealed-dispatch-capability.js +359 -0
  54. package/dist/core-runtime/llm/structured-dispatch-error.js +15 -0
  55. package/dist/core-runtime/onboard/claude-profile-scan.js +65 -0
  56. package/dist/core-runtime/onboard/cli-host.js +5 -2
  57. package/dist/core-runtime/onboard/configure-provider.js +33 -1
  58. package/dist/core-runtime/onboard/host-target.js +28 -3
  59. package/dist/core-runtime/onboard/register.js +54 -12
  60. package/dist/core-runtime/pipeline-execution-ledger.js +10 -0
  61. package/dist/core-runtime/reconstruct/artifact-types.js +35 -0
  62. package/dist/core-runtime/reconstruct/claim-projection-validation.js +17 -0
  63. package/dist/core-runtime/reconstruct/competency-projection-contract.js +63 -0
  64. package/dist/core-runtime/reconstruct/comprehension-artifact.js +321 -0
  65. package/dist/core-runtime/reconstruct/comprehension-reduce.js +367 -0
  66. package/dist/core-runtime/reconstruct/comprehension-semantic-map.js +771 -0
  67. package/dist/core-runtime/reconstruct/contract-registry.js +20 -0
  68. package/dist/core-runtime/reconstruct/dispatch-fallback-artifacts.js +640 -0
  69. package/dist/core-runtime/reconstruct/execution-telemetry.js +83 -9
  70. package/dist/core-runtime/reconstruct/final-output-sections.js +89 -0
  71. package/dist/core-runtime/reconstruct/governing-snapshot.js +36 -1
  72. package/dist/core-runtime/reconstruct/leaf-reader.js +305 -0
  73. package/dist/core-runtime/reconstruct/llm-dispatch-failure.js +270 -0
  74. package/dist/core-runtime/reconstruct/llm-touch-fingerprint.js +94 -0
  75. package/dist/core-runtime/reconstruct/material-admission-validation.js +18 -0
  76. package/dist/core-runtime/reconstruct/material-profile-validation.js +14 -0
  77. package/dist/core-runtime/reconstruct/materialize-preparation.js +304 -18
  78. package/dist/core-runtime/reconstruct/maturation-validation.js +1231 -53
  79. package/dist/core-runtime/reconstruct/mock-llm-realization.js +107 -3
  80. package/dist/core-runtime/reconstruct/obligation-assertion.js +18 -0
  81. package/dist/core-runtime/reconstruct/ontology-seed-validation.js +27 -0
  82. package/dist/core-runtime/reconstruct/output-budget.js +6 -0
  83. package/dist/core-runtime/reconstruct/pipeline-execution-ledger.js +87 -3
  84. package/dist/core-runtime/reconstruct/post-seed-validation.js +118 -5
  85. package/dist/core-runtime/reconstruct/purpose-authority-validation.js +117 -2
  86. package/dist/core-runtime/reconstruct/record.js +42 -0
  87. package/dist/core-runtime/reconstruct/registry-verification-validation.js +16 -0
  88. package/dist/core-runtime/reconstruct/run-control-validation.js +1011 -34
  89. package/dist/core-runtime/reconstruct/run.js +5198 -536
  90. package/dist/core-runtime/reconstruct/seed-authoring-readiness-validation.js +54 -3
  91. package/dist/core-runtime/reconstruct/semantic-quality-gate.js +105 -2
  92. package/dist/core-runtime/reconstruct/source-observation-delta-validation.js +41 -0
  93. package/dist/core-runtime/reconstruct/source-observations.js +166 -0
  94. package/dist/core-runtime/reconstruct/source-safety-validation.js +71 -183
  95. package/dist/core-runtime/reconstruct/source-scout-pack-validation.js +37 -1
  96. package/dist/core-runtime/reconstruct/terminal-validation.js +132 -2
  97. package/dist/core-runtime/review/continuation-plan.js +12 -5
  98. package/dist/core-runtime/review/issue-artifact-runtime.js +4 -1
  99. package/dist/core-runtime/review/materializers.js +100 -11
  100. package/dist/core-runtime/review/nesting-batch.js +6 -2
  101. package/dist/core-runtime/review/obligation-shardability.js +138 -0
  102. package/dist/core-runtime/review/pipeline-execution-ledger.js +42 -2
  103. package/dist/core-runtime/review/review-artifact-utils.js +217 -5
  104. package/dist/core-runtime/review/review-execution-steps.js +32 -8
  105. package/dist/core-runtime/review/review-progress-contract.js +8 -0
  106. package/dist/core-runtime/review/review-prompt-budget.js +97 -0
  107. package/dist/core-runtime/review/semantic-quality-gate.js +33 -7
  108. package/dist/core-runtime/review/spreadsheet-review-disposition.js +94 -0
  109. package/dist/core-runtime/route-identity.js +182 -0
  110. package/dist/core-runtime/spreadsheet-header-escalation.js +204 -0
  111. package/dist/core-runtime/spreadsheet-structure-observer.js +2689 -0
  112. package/dist/core-runtime/target-material-kind.js +58 -0
  113. package/dist/mcp/server.js +12 -5
  114. package/dist/tui/app.js +167 -0
  115. package/dist/tui/data/event-follower.js +116 -0
  116. package/dist/tui/data/node-detail.js +42 -0
  117. package/dist/tui/data/projection-poll.js +26 -0
  118. package/dist/tui/data/session-discovery.js +65 -0
  119. package/dist/tui/index.js +108 -0
  120. package/dist/tui/screens/log.js +25 -0
  121. package/dist/tui/screens/node-detail.js +17 -0
  122. package/dist/tui/screens/session-selector.js +26 -0
  123. package/dist/tui/screens/workflow-tree.js +90 -0
  124. package/dist/tui/view-model/reconstruct-adapter.js +162 -0
  125. package/dist/tui/view-model/review-adapter.js +270 -0
  126. package/dist/tui/view-model/tree-view-model.js +19 -0
  127. package/package.json +18 -3
  128. package/settings.example.json +77 -16
@@ -0,0 +1,2689 @@
1
+ // Spreadsheet structure observer (S1 — design:
2
+ // development-records/design/20260617-spreadsheet-extraction-adapter-s1-design.md).
3
+ //
4
+ // L1 format adapter: turns a workbook into a deterministic, runtime-owned
5
+ // `WorkbookStructuralInventory`. This is the missing "readable representation"
6
+ // layer that lets onto observe spreadsheet material at all (see §2.1a). It is
7
+ // LLM-FREE: it answers only "what does the workbook structurally contain", never
8
+ // "what does it mean" (the seam/seed stages own meaning).
9
+ //
10
+ // Shared cross-pipeline runtime (consumed by reconstruct + review), so it lives
11
+ // at the top level of core-runtime alongside target-material-kind.ts rather than
12
+ // under a pipeline subdir (repo-layout.md; import-boundary).
13
+ //
14
+ // This file ships P0 (the inventory type + envelope), P1 (the pure-Node,
15
+ // zero-dependency CSV extractor) and P4 (xlsx/xlsm via the bundled read-only
16
+ // fflate + saxes stack — streaming unzip + SAX, design §11.2). xls (BIFF) and
17
+ // ods (a different ZIP/XML schema) still return `unsupported_reason`.
18
+ //
19
+ // Capability boundary (design §1.2, §10 C′): extraction here is deterministic and
20
+ // uses only deterministic heuristics for the judgment calls (header detection,
21
+ // column typing, categorical detection). LLM escalation for ambiguous layouts is
22
+ // a separate, named step (design §10 / §11 ESC-1) and is NOT part of this module.
23
+ //
24
+ // The DATA-OBSERVATION layer is aggregate-counts-only by default — `distinct_value_vocab`
25
+ // carries `distinct_count` but NOT raw `top_values`, no raw sample rows / cell values are
26
+ // emitted, and per-column cardinality (distinct_count / non_empty_count) is COUNTS only.
27
+ // The ONE narrowed exception (design-C, NOT a preserved guarantee): `data_validations[].members`
28
+ // carries the DECLARED type=list enum labels parsed from an INLINE formula1 literal — bounded,
29
+ // and never sourced from observed cell values (so off-list / free / high-cardinality raw values
30
+ // are still never emitted). The inventory is a structural / aggregate index, not a data dump.
31
+ // Scope note: column/header NAMES are emitted as structural schema (a header is,
32
+ // by definition, labels — not data). Deterministic header detection can misread a
33
+ // headerless all-text sheet's first DATA row as a header, surfacing that one row
34
+ // as column names; such sheets are flagged `header_confidence: "low"` (see
35
+ // detectHeaderRow) and are the intended targets of P0.5 LLM escalation. The
36
+ // aggregate no-raw-VALUE guarantee above is unaffected.
37
+ import crypto from "node:crypto";
38
+ import fs from "node:fs/promises";
39
+ import path from "node:path";
40
+ import { Unzip, UnzipInflate } from "fflate";
41
+ import { SaxesParser } from "saxes";
42
+ export const SPREADSHEET_OBSERVER_ADAPTER_ID = "spreadsheet-structure-observer";
43
+ // v2 (Stage 1.1): per-cell `formula_cells` replaced by deduplicated `formula_patterns`
44
+ // (tier-1 exact-text dedup) + an honest `formula_cells_total`. A bump invalidates a
45
+ // resume's reuse hash (run.ts sourceObservationsReuseSha256) so a stale old-schema seed
46
+ // is never silently reused.
47
+ // v3 (design-C): per-column cardinality (distinct_count / distinct_count_is_estimate /
48
+ // non_empty_count on every profiled column) + declared type=list enum members on
49
+ // data_validations (validation_type / members / members_truncated / applies_to_columns).
50
+ // v4 (P1-C1): segmented value-tile projection promoted to production.
51
+ // v5 (P1-C2-B′ #3): per-column `is_uniform_formula` — true when a single single-column formula
52
+ // pattern provably covers the column's data cells (a repeated/shared fill-down). The leaf-read
53
+ // trigger skips such columns (structure fully captures them). Additive optional field.
54
+ export const SPREADSHEET_OBSERVER_ADAPTER_VERSION = 5;
55
+ export const DEFAULT_DATA_LAYER_CAPS = {
56
+ max_rows_scanned_per_sheet: 100_000,
57
+ max_distinct_tracked_per_column: 256,
58
+ max_columns_profiled: 512,
59
+ max_sheet_pairs: 64,
60
+ max_sheets_observed: 2048,
61
+ };
62
+ // design-C declared type=list enum-member bounds.
63
+ /** Max declared enum members emitted per type=list validation. Reuses the existing
64
+ * categorical gate ceiling (the distinct_value_vocab `<= 50` rule below) so "a bounded
65
+ * controlled vocabulary" means the same count everywhere. Over this → members absent +
66
+ * members_truncated. */
67
+ export const VALIDATION_MEMBER_COUNT_CAP = 50;
68
+ /** Max characters per declared enum member. PRELIMINARY / bench-calibrated: enum labels are
69
+ * short, so a long "member" signals the formula1 is not really an enum (e.g. a long range
70
+ * expression). Any member over this → members absent + members_truncated. */
71
+ export const VALIDATION_MEMBER_CHAR_CAP = 64;
72
+ /** The ONE pure cardinality-priority calc point (design-C §2.1): selection, display, and the
73
+ * route-less mirror all read THIS so they cannot drift. Total order is
74
+ * (is_estimate ? 1 : 0) DESC, then ratio DESC, then column.index ASC, where
75
+ * ratio = non_empty_count === 0 ? 0 : distinct_count / non_empty_count (never NaN). A
76
+ * distinct-cap estimate is a lower bound, so it is treated as MAXIMALly informative (kept
77
+ * first) — the is_estimate term lives ONLY here, never duplicated at a call site. */
78
+ export function columnResidualKey(col) {
79
+ const ratio = col.non_empty_count === 0 ? 0 : col.distinct_count / col.non_empty_count;
80
+ return { estimate: col.distinct_count_is_estimate ? 1 : 0, ratio, index: col.index };
81
+ }
82
+ /** Descending residual-priority comparator over {@link columnResidualKey} (highest priority
83
+ * first): estimate DESC, ratio DESC, index ASC. Deterministic — equal estimate+ratio break
84
+ * by index, so the order is total. */
85
+ export function compareColumnResidualDesc(a, b) {
86
+ const ka = columnResidualKey(a);
87
+ const kb = columnResidualKey(b);
88
+ if (ka.estimate !== kb.estimate)
89
+ return kb.estimate - ka.estimate;
90
+ if (ka.ratio !== kb.ratio)
91
+ return kb.ratio - ka.ratio;
92
+ return ka.index - kb.index;
93
+ }
94
+ // ───────────────────────── P6: honesty/provenance gate support ─────────────────────────
95
+ //
96
+ // These live next to the inventory type so the PRODUCER (the materialize summary
97
+ // builder) and the GATE (validateSourceObservationBoundary) bind to the SAME
98
+ // literals and helper and cannot drift. `inspection_method` needs no runtime
99
+ // assertion: it is a single-literal type written as a const by every emitter, so
100
+ // the type already makes "claims recompute" unrepresentable (capability boundary).
101
+ /** Fixed disclosure phrase the observation summary MUST carry when the inventory
102
+ * reports partial structural capture (P6 assertion D). */
103
+ export const SPREADSHEET_CAPTURE_TRUNCATED_PHRASE = "capture_truncated: structural capture hit a budget cap (partial structural evidence)";
104
+ /** Fixed disclosure phrase the observation summary MUST carry when the workbook
105
+ * carries macro/VBA code (P6: an emitted honesty/safety signal — structure-only
106
+ * inspection cannot vouch for executable behavior). */
107
+ export const SPREADSHEET_MACRO_PRESENT_PHRASE = "macro_present: workbook carries macro/VBA code (structure inspected only; behavior not vouched)";
108
+ /** True when an inventory carries any INSPECTED structure across the FULL inventory
109
+ * surface: a sheet with non-zero dimensions, any structural array (including derived
110
+ * cross_sheet_key_overlap and risk_signals, which can only exist after a workbook was
111
+ * read+profiled), profiled columns, or distinct-value vocab. An UNSUPPORTED inventory
112
+ * must NOT claim inspected structure — it carries only an `unsupported_reason` (P6
113
+ * assertion C). The legitimate empty-csv placeholder (a zero-dimension sheet shell with
114
+ * no columns) and `unsupportedInventory()` (`sheets: []`, all arrays empty) both return
115
+ * false here, so the honesty gate passes those honest states rather than crashing them. */
116
+ export function inventoryHasInspectedStructure(inventory) {
117
+ return (inventory.sheets.some((sheet) => sheet.dimensions.rows > 0 || sheet.dimensions.cols > 0) ||
118
+ inventory.named_ranges.length > 0 ||
119
+ inventory.tables.length > 0 ||
120
+ inventory.pivot_tables.length > 0 ||
121
+ inventory.formula_patterns.length > 0 ||
122
+ inventory.merged_ranges.length > 0 ||
123
+ inventory.data_validations.length > 0 ||
124
+ inventory.external_links.length > 0 ||
125
+ inventory.error_cells.length > 0 ||
126
+ inventory.distinct_value_vocab.length > 0 ||
127
+ inventory.cross_sheet_key_overlap.length > 0 ||
128
+ inventory.risk_signals.length > 0 ||
129
+ (inventory.segmented_value_tiles?.some((s) => s.columns.length > 0) ?? false) ||
130
+ inventory.per_sheet_data.some((sheet) => sheet.columns.length > 0));
131
+ }
132
+ /** Stricter than {@link inventoryHasInspectedStructure} for REVIEW inspectability: a
133
+ * workbook backs a review obligation only when it rendered ACTUAL renderable structure —
134
+ * sheet bodies, formulas, named ranges, tables, validations, links, vocab, cross-sheet
135
+ * overlap, OR access/protection hygiene evidence (a macro project, a protected sheet).
136
+ * Error/risk signals ALONE do not count: a corrupt workbook whose worksheet parts are all
137
+ * unreadable emits `unreadable_sheet_part` risk signals over zero-dimension sheets, which
138
+ * `inventoryHasInspectedStructure` counts as "inspected" (its job is P6 provenance —
139
+ * "something was observed") but which give a reviewer no structure to audit. Review uses
140
+ * this view so a spreadsheet obligation is never attached to a workbook whose only
141
+ * evidence is an error signal — while still treating a macro-only or protection-only
142
+ * `.xlsm` as inspectable, since access_and_protection_hygiene has real evidence there
143
+ * (a corrupt shell has neither: its macro flag is false and protection is unknowable
144
+ * without a readable worksheet part). error_cells are excluded: a real error cell always
145
+ * lives on a non-zero-dimension sheet, already covered above. */
146
+ export function inventoryHasRenderableStructure(inventory) {
147
+ return (inventory.sheets.some((sheet) => sheet.dimensions.rows > 0 || sheet.dimensions.cols > 0) ||
148
+ inventory.macro_present ||
149
+ inventory.sheets.some((sheet) => sheet.protected) ||
150
+ inventory.named_ranges.length > 0 ||
151
+ inventory.tables.length > 0 ||
152
+ inventory.pivot_tables.length > 0 ||
153
+ inventory.formula_patterns.length > 0 ||
154
+ inventory.merged_ranges.length > 0 ||
155
+ inventory.data_validations.length > 0 ||
156
+ inventory.external_links.length > 0 ||
157
+ inventory.distinct_value_vocab.length > 0 ||
158
+ inventory.cross_sheet_key_overlap.length > 0 ||
159
+ (inventory.segmented_value_tiles?.some((s) => s.columns.length > 0) ?? false) ||
160
+ inventory.per_sheet_data.some((sheet) => sheet.columns.length > 0));
161
+ }
162
+ // ───────────────────────── P1: CSV extractor (pure Node, zero-dep) ─────────────────────────
163
+ const CSV_DELIMITERS = [",", "\t", ";"];
164
+ /** Count occurrences of `delim` in `line` that fall OUTSIDE double-quoted fields,
165
+ * so a delimiter inside a quoted free-text cell doesn't skew detection. */
166
+ function countDelimiterOutsideQuotes(line, delim) {
167
+ let count = 0;
168
+ let inQuotes = false;
169
+ for (let i = 0; i < line.length; i += 1) {
170
+ const ch = line[i];
171
+ if (ch === '"')
172
+ inQuotes = !inQuotes;
173
+ else if (!inQuotes && ch === delim)
174
+ count += 1;
175
+ }
176
+ return count;
177
+ }
178
+ /** Pick the delimiter with the most occurrences across the first several
179
+ * non-empty lines (deterministic; comma wins ties by being first). Scanning
180
+ * more than one line keeps a leading title/blank line — common in exported
181
+ * reports — from mis-picking the delimiter, and quoted fields are ignored so a
182
+ * semicolon inside a comma file's text column isn't counted. */
183
+ function detectDelimiter(text) {
184
+ const lines = text
185
+ .split(/\r?\n/)
186
+ .filter((l) => l.trim().length > 0)
187
+ .slice(0, 10);
188
+ let best = ",";
189
+ let bestCount = -1;
190
+ for (const d of CSV_DELIMITERS) {
191
+ let count = 0;
192
+ for (const line of lines)
193
+ count += countDelimiterOutsideQuotes(line, d);
194
+ if (count > bestCount) {
195
+ best = d;
196
+ bestCount = count;
197
+ }
198
+ }
199
+ return best;
200
+ }
201
+ /** RFC4180-ish parser: quoted fields, "" escapes, embedded delimiters/newlines.
202
+ * Stops after `maxRows` parsed records (caps); reports whether more remained. */
203
+ export function parseCsv(text, delimiter, maxRows) {
204
+ const rows = [];
205
+ let field = "";
206
+ let row = [];
207
+ let inQuotes = false;
208
+ let rowStarted = false;
209
+ let truncated = false;
210
+ const pushField = () => {
211
+ row.push(field);
212
+ field = "";
213
+ };
214
+ const pushRow = () => {
215
+ pushField();
216
+ rows.push(row);
217
+ row = [];
218
+ rowStarted = false;
219
+ };
220
+ for (let i = 0; i < text.length; i += 1) {
221
+ if (rows.length >= maxRows) {
222
+ truncated = true;
223
+ break;
224
+ }
225
+ const ch = text[i];
226
+ rowStarted = true;
227
+ if (inQuotes) {
228
+ if (ch === '"') {
229
+ if (text[i + 1] === '"') {
230
+ field += '"';
231
+ i += 1;
232
+ }
233
+ else {
234
+ inQuotes = false;
235
+ }
236
+ }
237
+ else {
238
+ field += ch;
239
+ }
240
+ continue;
241
+ }
242
+ if (ch === '"') {
243
+ inQuotes = true;
244
+ }
245
+ else if (ch === delimiter) {
246
+ pushField();
247
+ }
248
+ else if (ch === "\n") {
249
+ pushRow();
250
+ }
251
+ else if (ch === "\r") {
252
+ if (text[i + 1] === "\n")
253
+ i += 1;
254
+ pushRow();
255
+ }
256
+ else {
257
+ field += ch;
258
+ }
259
+ }
260
+ // Flush trailing field/row unless we stopped on a clean row boundary.
261
+ if (rows.length < maxRows && (rowStarted || field.length > 0 || row.length > 0)) {
262
+ pushRow();
263
+ }
264
+ return { rows, truncated };
265
+ }
266
+ const INTEGER_RE = /^-?\d+$/;
267
+ const NUMBER_RE = /^-?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][-+]?\d+)?$/;
268
+ const BOOLEAN_RE = /^(?:true|false)$/i;
269
+ // Conservative date shapes: ISO (2026-06-17), and common slash/dot dates.
270
+ const DATE_RE = /^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}$|^\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}$/;
271
+ function classifyValue(raw) {
272
+ const v = raw.trim();
273
+ if (BOOLEAN_RE.test(v))
274
+ return "boolean";
275
+ if (INTEGER_RE.test(v))
276
+ return "integer";
277
+ if (NUMBER_RE.test(v))
278
+ return "number";
279
+ if (DATE_RE.test(v))
280
+ return "date";
281
+ return "string";
282
+ }
283
+ // Header detection scans the first rows (real workbooks put title / blank rows
284
+ // above the header) and scores each as fill_ratio × label_ratio: a header is a
285
+ // mostly-filled row of mostly-non-numeric labels. Deterministic; the residual
286
+ // ambiguous tail is flagged low-confidence rather than resolved by an LLM here.
287
+ const HEADER_SCAN_ROWS = 15;
288
+ const HEADER_SCORE_STRONG = 0.45;
289
+ const HEADER_SCORE_WEAK = 0.15;
290
+ /** fill_ratio × label_ratio of a candidate header row (0 when blank). */
291
+ function scoreHeaderRow(cells, colCount) {
292
+ if (colCount === 0)
293
+ return 0;
294
+ let nonEmpty = 0;
295
+ let nonNumeric = 0;
296
+ for (const c of cells) {
297
+ const t = c.trim();
298
+ if (t.length === 0)
299
+ continue;
300
+ nonEmpty += 1;
301
+ const kind = classifyValue(t);
302
+ if (kind !== "integer" && kind !== "number")
303
+ nonNumeric += 1;
304
+ }
305
+ if (nonEmpty === 0)
306
+ return 0;
307
+ return (nonEmpty / colCount) * (nonNumeric / nonEmpty);
308
+ }
309
+ /** Whether the rows just below `headerRow` carry typed (numeric/date/boolean)
310
+ * data — the contrast that distinguishes a real header from a first DATA row of
311
+ * strings. Without it, an all-text first row is indistinguishable from data. */
312
+ function hasDataTypeContrast(rows, headerRow) {
313
+ const end = Math.min(rows.length, headerRow + 1 + 30);
314
+ for (let r = headerRow + 1; r < end; r += 1) {
315
+ for (const cell of rows[r] ?? []) {
316
+ const t = cell.trim();
317
+ if (t.length === 0)
318
+ continue;
319
+ const k = classifyValue(t);
320
+ if (k === "integer" || k === "number" || k === "date" || k === "boolean")
321
+ return true;
322
+ }
323
+ }
324
+ return false;
325
+ }
326
+ /** Pick the best header row in the first HEADER_SCAN_ROWS (skipping title/blank
327
+ * rows above it) and rate confidence. A strong label row is HIGH confidence only
328
+ * when the data below shows type contrast — an all-text first row over all-text
329
+ * data is indistinguishable from data, so it is flagged LOW (its cells could be
330
+ * raw values, not schema; Codex P1). A populated sheet with no label-like row is
331
+ * a headerless matrix, also flagged low. Both are escalation candidates. */
332
+ function detectHeaderRow(rows, colCount) {
333
+ const scanLimit = Math.min(HEADER_SCAN_ROWS, rows.length);
334
+ let bestRow = -1;
335
+ let bestScore = 0;
336
+ for (let r = 0; r < scanLimit; r += 1) {
337
+ const score = scoreHeaderRow(rows[r] ?? [], colCount);
338
+ if (score > bestScore) {
339
+ bestScore = score;
340
+ bestRow = r;
341
+ }
342
+ }
343
+ if (bestScore >= HEADER_SCORE_STRONG) {
344
+ const confidence = hasDataTypeContrast(rows, bestRow) ? "high" : "low";
345
+ return { headerRowIndex: bestRow, confidence };
346
+ }
347
+ if (bestScore >= HEADER_SCORE_WEAK)
348
+ return { headerRowIndex: bestRow, confidence: "low" };
349
+ return { headerRowIndex: null, confidence: "low" };
350
+ }
351
+ function sha256Hex(bytes) {
352
+ return crypto.createHash("sha256").update(bytes).digest("hex");
353
+ }
354
+ /** Deterministic per-sheet data profiling shared by every format (csv = one
355
+ * sheet; xlsx = one call per sheet). Given the already-parsed cell grid (header
356
+ * + data rows, already bounded by the row cap upstream), derive header detection,
357
+ * column types, aggregate distinct counts, and ragged-row risk signals. Holds NO
358
+ * raw values beyond bounded distinct sets it counts internally. */
359
+ function profileSheetRows(args) {
360
+ const { sheetName, rows, caps } = args;
361
+ if (rows.length === 0) {
362
+ return {
363
+ layout_kind: "unknown",
364
+ header_rows: null,
365
+ header_confidence: "low",
366
+ columns: [],
367
+ distinct_value_vocab: [],
368
+ risk_signals: [],
369
+ data_layer_truncated: false,
370
+ col_count: 0,
371
+ row_count: 0,
372
+ column_value_sets: new Map(),
373
+ };
374
+ }
375
+ const colCount = rows.reduce((m, r) => Math.max(m, r.length), 0);
376
+ const { headerRowIndex, confidence } = detectHeaderRow(rows, colCount);
377
+ const hasHeader = headerRowIndex !== null;
378
+ const headerCells = hasHeader ? (rows[headerRowIndex] ?? []) : [];
379
+ const headerRows = hasHeader ? [headerRowIndex] : null;
380
+ // Data begins after the detected header row; rows above it (titles/blanks) are
381
+ // dropped from the data scan.
382
+ const headerStart = hasHeader ? headerRowIndex + 1 : 0;
383
+ const dataRows = rows.slice(headerStart);
384
+ const profiledCols = Math.min(colCount, caps.max_columns_profiled);
385
+ const columnName = (idx) => {
386
+ const raw = hasHeader ? (headerCells[idx] ?? "").trim() : "";
387
+ return raw.length > 0 ? raw : `col_${idx + 1}`;
388
+ };
389
+ // Ragged rows (inconsistent column count) recorded literally, capped.
390
+ const risk_signals = [];
391
+ const RAGGED_CAP = 20;
392
+ for (let r = 0; r < dataRows.length && risk_signals.length < RAGGED_CAP; r += 1) {
393
+ const dr = dataRows[r];
394
+ // A fully-empty row is a blank/omitted row (sparse gap), not a ragged row.
395
+ if (dr && dr.length > 0 && dr.length !== colCount) {
396
+ risk_signals.push({
397
+ kind: "ragged_row",
398
+ location: `${sheetName}:row ${headerStart + r + 1}`,
399
+ literal: `${dr.length} cols vs ${colCount}`,
400
+ });
401
+ }
402
+ }
403
+ const columns = [];
404
+ const distinct_value_vocab = [];
405
+ // Retained only for tabular sheets with a real column name — fuels cross-sheet
406
+ // key overlap; never emitted. Bounded by the distinct cap per column.
407
+ const column_value_sets = new Map();
408
+ let dataLayerTruncated = false;
409
+ for (let c = 0; c < profiledCols; c += 1) {
410
+ const typeCounts = new Map();
411
+ const distinct = new Set();
412
+ let distinctEstimate = false;
413
+ let nonEmpty = 0;
414
+ for (const dr of dataRows) {
415
+ const cell = (dr[c] ?? "").trim();
416
+ if (cell.length === 0)
417
+ continue;
418
+ nonEmpty += 1;
419
+ const kind = classifyValue(cell);
420
+ typeCounts.set(kind, (typeCounts.get(kind) ?? 0) + 1);
421
+ if (distinct.size < caps.max_distinct_tracked_per_column) {
422
+ distinct.add(cell);
423
+ }
424
+ else if (!distinct.has(cell)) {
425
+ distinctEstimate = true;
426
+ }
427
+ }
428
+ // Majority type among non-empty values; empty column → "empty".
429
+ let inferred = "empty";
430
+ let bestCount = 0;
431
+ // Deterministic tie-break by the fixed enum order below.
432
+ for (const t of ["string", "date", "number", "integer", "boolean"]) {
433
+ const n = typeCounts.get(t) ?? 0;
434
+ if (n > bestCount) {
435
+ bestCount = n;
436
+ inferred = t;
437
+ }
438
+ }
439
+ const denom = dataRows.length === 0 ? 0 : nonEmpty / dataRows.length;
440
+ columns.push({
441
+ name: columnName(c),
442
+ index: c,
443
+ inferred_type: inferred,
444
+ non_empty_ratio: Math.round(denom * 1000) / 1000,
445
+ // design-C per-column cardinality, O(1) from the scan above: distinct value COUNT
446
+ // (cap-aware lower bound when distinctEstimate) over the SCANNED non-empty cells.
447
+ distinct_count: distinctEstimate ? caps.max_distinct_tracked_per_column : distinct.size,
448
+ distinct_count_is_estimate: distinctEstimate,
449
+ non_empty_count: nonEmpty,
450
+ });
451
+ // Categorical → controlled-vocab candidate. Deterministic rule: a bounded,
452
+ // repeating set of non-unique values. AGGREGATE COUNT ONLY: no raw top_values
453
+ // are emitted here (the per-column distinct_count above is likewise a COUNT, not a
454
+ // value list; declared type=list enum LABELS live only on data_validations.members,
455
+ // sourced from formula1 — never from these observed values).
456
+ if (distinctEstimate) {
457
+ // Hit the distinct cap → count is a lower-bound estimate; flag truncation (CAPS-1).
458
+ dataLayerTruncated = true;
459
+ distinct_value_vocab.push({
460
+ sheet: sheetName,
461
+ column: columnName(c),
462
+ distinct_count: caps.max_distinct_tracked_per_column,
463
+ distinct_count_is_estimate: true,
464
+ });
465
+ }
466
+ else if (nonEmpty > 0 && distinct.size >= 1 && distinct.size <= 50 && distinct.size < nonEmpty) {
467
+ distinct_value_vocab.push({
468
+ sheet: sheetName,
469
+ column: columnName(c),
470
+ distinct_count: distinct.size,
471
+ distinct_count_is_estimate: false,
472
+ });
473
+ }
474
+ // Cross-sheet key/dimension candidate: a real-named tabular column with ≥2
475
+ // distinct values. The bounded set stays internal.
476
+ if (hasHeader && distinct.size >= 2) {
477
+ const name = columnName(c);
478
+ if (!name.startsWith("col_"))
479
+ column_value_sets.set(name, distinct);
480
+ }
481
+ }
482
+ const layout = hasHeader ? "tabular" : "matrix_no_header";
483
+ return {
484
+ layout_kind: layout,
485
+ header_rows: headerRows,
486
+ header_confidence: confidence,
487
+ columns: layout === "tabular" ? columns : [],
488
+ distinct_value_vocab,
489
+ risk_signals,
490
+ data_layer_truncated: dataLayerTruncated,
491
+ col_count: colCount,
492
+ row_count: rows.length,
493
+ column_value_sets,
494
+ };
495
+ }
496
+ /** Cross-sheet key/dimension overlap (design §2.4): for each column NAME shared by
497
+ * ≥2 sheets, the pairwise count of shared values between their (bounded) value
498
+ * sets — a data-level relationship signal. Counts only; the total number of pairs
499
+ * is bounded by max_sheet_pairs (CAPS-1). */
500
+ function computeCrossSheetKeyOverlap(perSheet, caps) {
501
+ const byKey = new Map();
502
+ for (const { sheet, valueSets } of perSheet) {
503
+ for (const [col, values] of valueSets) {
504
+ const list = byKey.get(col);
505
+ if (list)
506
+ list.push({ sheet, values });
507
+ else
508
+ byKey.set(col, [{ sheet, values }]);
509
+ }
510
+ }
511
+ const overlaps = [];
512
+ let pairBudget = caps.max_sheet_pairs;
513
+ let truncated = false;
514
+ for (const [key, occ] of byKey) {
515
+ if (occ.length < 2)
516
+ continue;
517
+ const pairwise = [];
518
+ for (let i = 0; i < occ.length; i += 1) {
519
+ for (let j = i + 1; j < occ.length; j += 1) {
520
+ if (pairBudget <= 0) {
521
+ truncated = true;
522
+ break;
523
+ }
524
+ pairBudget -= 1;
525
+ const a = occ[i];
526
+ const b = occ[j];
527
+ const [small, large] = a.values.size <= b.values.size ? [a.values, b.values] : [b.values, a.values];
528
+ let count = 0;
529
+ for (const v of small)
530
+ if (large.has(v))
531
+ count += 1;
532
+ if (count > 0)
533
+ pairwise.push({ a: a.sheet, b: b.sheet, count });
534
+ }
535
+ if (pairBudget <= 0)
536
+ break;
537
+ }
538
+ if (pairwise.length > 0) {
539
+ overlaps.push({ key_name: key, sheets: occ.map((o) => o.sheet), pairwise_overlap: pairwise });
540
+ }
541
+ }
542
+ return { overlaps, truncated };
543
+ }
544
+ /** Build the inventory from already-read CSV text. Pure & deterministic:
545
+ * identical (sourceRef, content, contentSha256, caps) → identical inventory. */
546
+ export function buildCsvInventory(args) {
547
+ const caps = args.caps ?? DEFAULT_DATA_LAYER_CAPS;
548
+ const sheetName = path.basename(args.sourceRef);
549
+ // Strip a leading UTF-8 BOM (common in Excel CSV exports) so it doesn't attach
550
+ // to the first header cell. content_sha256 stays the raw-byte hash (with BOM).
551
+ const content = args.content.charCodeAt(0) === 0xfeff ? args.content.slice(1) : args.content;
552
+ const delimiter = args.delimiter ?? detectDelimiter(content);
553
+ // +1 so a header row never eats into the data-row scan budget.
554
+ const { rows, truncated: rowsTruncated } = parseCsv(content, delimiter, caps.max_rows_scanned_per_sheet + 1);
555
+ const envelope = {
556
+ adapter_id: SPREADSHEET_OBSERVER_ADAPTER_ID,
557
+ adapter_version: SPREADSHEET_OBSERVER_ADAPTER_VERSION,
558
+ source_ref: path.resolve(args.sourceRef),
559
+ content_sha256: args.contentSha256,
560
+ workbook_kind: (delimiter === "\t" ? "tsv" : "csv"),
561
+ inspection_method: "structure_inspected_only",
562
+ named_ranges: [],
563
+ tables: [],
564
+ pivot_tables: [],
565
+ formula_patterns: [],
566
+ formula_cells_total: 0,
567
+ formula_cells_total_is_lower_bound: false,
568
+ merged_ranges: [],
569
+ data_validations: [],
570
+ external_links: [],
571
+ error_cells: [],
572
+ macro_present: false,
573
+ cross_sheet_key_overlap: [], // single logical sheet
574
+ data_layer_caps: caps,
575
+ };
576
+ if (rows.length === 0) {
577
+ return {
578
+ ...envelope,
579
+ sheets: [{ name: sheetName, used_range: null, dimensions: { rows: 0, cols: 0 }, hidden: false, protected: false }],
580
+ risk_signals: [],
581
+ per_sheet_data: [{ sheet: sheetName, layout_kind: "unknown", header_rows: null, header_confidence: "low", columns: [] }],
582
+ distinct_value_vocab: [],
583
+ capture_truncated: rowsTruncated,
584
+ unsupported_reason: "empty csv (no rows)",
585
+ };
586
+ }
587
+ const profile = profileSheetRows({ sheetName, rows, caps });
588
+ const colsTruncated = profile.col_count > caps.max_columns_profiled;
589
+ return {
590
+ ...envelope,
591
+ sheets: [
592
+ {
593
+ name: sheetName,
594
+ used_range: `R1C1:R${profile.row_count}C${profile.col_count}`,
595
+ dimensions: { rows: profile.row_count, cols: profile.col_count },
596
+ hidden: false,
597
+ protected: false,
598
+ },
599
+ ],
600
+ risk_signals: profile.risk_signals,
601
+ per_sheet_data: [
602
+ {
603
+ sheet: sheetName,
604
+ layout_kind: profile.layout_kind,
605
+ header_rows: profile.header_rows,
606
+ header_confidence: profile.header_confidence,
607
+ columns: profile.columns,
608
+ },
609
+ ],
610
+ distinct_value_vocab: profile.distinct_value_vocab,
611
+ capture_truncated: rowsTruncated || colsTruncated || profile.data_layer_truncated,
612
+ unsupported_reason: null,
613
+ };
614
+ }
615
+ // ───────────────────────── P4: xlsx/xlsm extractor (fflate + saxes) ─────────────────────────
616
+ //
617
+ // Read-only OOXML structure extraction, STREAMING. fflate's streaming Unzip feeds
618
+ // each entry's decompressed chunks incrementally so a multi-hundred-MB worksheet
619
+ // is never held whole — worksheet chunks are piped straight into a streaming SAX
620
+ // parser and the per-sheet cell scan is bounded by data_layer_caps (cell VALUE
621
+ // work stops at the row cap; structural parts after <sheetData> are still seen).
622
+ // Pass 1 collects the small parts (workbook, rels, sharedStrings, tables); pass 2
623
+ // streams the worksheets (sharedStrings now resolved). No writing, no formula
624
+ // recalculation, no whole-workbook object model.
625
+ /** Byte budget for an ACCUMULATED small part (workbook/sharedStrings/rels/table).
626
+ * Worksheets are streamed and not subject to it. Counted from decompressed
627
+ * chunks (the streaming local header may omit sizes), and on overflow the part
628
+ * is dropped (capture_truncated + risk signal) rather than held in memory. */
629
+ const XLSX_PART_BYTE_BUDGET = 128 * 1024 * 1024;
630
+ // Repurposed (Stage 1.1) as a DISTINCT-pattern cap: the max number of distinct formula
631
+ // texts retained per sheet. occurrence_count keeps accumulating past it, and every cell is
632
+ // still counted in formula_cells_total; a new distinct pattern beyond it is dropped and
633
+ // flagged via formula_patterns_capped (an honest lower bound).
634
+ const XLSX_FORMULA_CAP = 5000;
635
+ // Bounded, display-only list of cell addresses per pattern (no exact authority).
636
+ const XLSX_FORMULA_APPLIED_RANGE_CAP = 8;
637
+ const XLSX_MERGE_CAP = 2000;
638
+ const XLSX_DATAVALIDATION_CAP = 1000;
639
+ const XLSX_ERROR_CELL_CAP = 1000;
640
+ function attrsOf(node) {
641
+ return node.attributes;
642
+ }
643
+ /** "A"→0, "Z"→25, "AA"→26 (0-based column index). */
644
+ function columnLettersToIndex(letters) {
645
+ let n = 0;
646
+ for (let i = 0; i < letters.length; i += 1) {
647
+ n = n * 26 + (letters.charCodeAt(i) - 64);
648
+ }
649
+ return n - 1;
650
+ }
651
+ /** "A1" / "$A$1" → { col, row } (0-based col, 1-based row); null if unparseable. */
652
+ function parseCellRef(ref) {
653
+ const m = /^\$?([A-Z]+)\$?(\d+)$/.exec(ref);
654
+ if (!m)
655
+ return null;
656
+ return { col: columnLettersToIndex(m[1]), row: parseInt(m[2], 10) };
657
+ }
658
+ /** "A1:G100" / "B2:D10" / "A1" → the SPAN (rows × cols actually used) and the
659
+ * used_range in R1C1 notation preserving the offset; null if unparseable. A sheet
660
+ * whose dimension starts below/right of A1 (title rows, left margins) reports its
661
+ * real span and origin, not a bounding box from A1. */
662
+ function parseDimension(ref) {
663
+ const parts = ref.split(":");
664
+ const a = parseCellRef((parts[0] ?? "").trim());
665
+ const b = parseCellRef((parts[parts.length - 1] ?? "").trim());
666
+ if (!a || !b)
667
+ return null;
668
+ const startRow = Math.min(a.row, b.row);
669
+ const endRow = Math.max(a.row, b.row);
670
+ const startCol = Math.min(a.col, b.col);
671
+ const endCol = Math.max(a.col, b.col);
672
+ return {
673
+ dims: { rows: endRow - startRow + 1, cols: endCol - startCol + 1 },
674
+ usedRange: `R${startRow}C${startCol + 1}:R${endRow}C${endCol + 1}`,
675
+ startRow,
676
+ startCol,
677
+ };
678
+ }
679
+ /** Sheet names referenced by a formula via the `Sheet!`/`'Sheet Name'!` prefix
680
+ * (a cross-sheet relationship signal), excluding self-references. */
681
+ function extractCrossSheetRefs(formula, currentSheet) {
682
+ const refs = new Set();
683
+ // Unicode-aware (sheet names are commonly non-ASCII, e.g. Korean). The
684
+ // negative lookbehind drops Excel error tokens like `#REF!` (a `#`-prefixed
685
+ // name followed by `!` is an error, not a sheet reference).
686
+ // Quoted sheet names may contain doubled apostrophes ('Bob''s Sheet'); allow
687
+ // them in the quoted branch, then unescape '' → ' below.
688
+ const re = /(?<!#)(?:'((?:[^']|'')+)'|([\p{L}_][\p{L}\p{N}_.]*))!/gu;
689
+ let m;
690
+ while ((m = re.exec(formula)) !== null) {
691
+ const name = (m[1] ?? m[2] ?? "").replace(/''/g, "'");
692
+ if (name && name !== currentSheet)
693
+ refs.add(name);
694
+ }
695
+ return [...refs];
696
+ }
697
+ /** Feed the in-memory archive to a streaming Unzip in bounded input chunks. This
698
+ * is what makes it genuinely streaming: pushing the whole buffer at once lets the
699
+ * sync inflater emit a huge entry as one chunk (which would also blow past V8's
700
+ * ~512MB max string length when decoded). 1 MiB input chunks keep the inflated
701
+ * output — and our decoded strings fed to SAX — small. */
702
+ function pushArchive(unzip, bytes) {
703
+ const CHUNK = 1 << 20;
704
+ if (bytes.length === 0) {
705
+ unzip.push(new Uint8Array(0), true);
706
+ return;
707
+ }
708
+ for (let off = 0; off < bytes.length; off += CHUNK) {
709
+ const end = Math.min(off + CHUNK, bytes.length);
710
+ unzip.push(bytes.subarray(off, end), end >= bytes.length);
711
+ }
712
+ }
713
+ /** Stream the archive once, decoding only the entries `want()` selects into a
714
+ * name→text map. Each accepted entry is accumulated from decompressed chunks and
715
+ * dropped if it exceeds XLSX_PART_BYTE_BUDGET (memory guard / zip-bomb defense).
716
+ * `onMacro` fires for xl/vbaProject.bin without decompressing it. Synchronous:
717
+ * fflate's UnzipInflate runs the callbacks during push. */
718
+ function streamCollectEntries(args) {
719
+ const out = new Map();
720
+ const unzip = new Unzip();
721
+ unzip.register(UnzipInflate);
722
+ unzip.onfile = (file) => {
723
+ if (file.name === "xl/vbaProject.bin") {
724
+ args.onMacro(file.name);
725
+ return;
726
+ }
727
+ if (!args.want(file.name))
728
+ return;
729
+ const chunks = [];
730
+ let total = 0;
731
+ let oversized = false;
732
+ file.ondata = (err, chunk, final) => {
733
+ if (err || oversized)
734
+ return;
735
+ total += chunk.length;
736
+ if (total > XLSX_PART_BYTE_BUDGET) {
737
+ oversized = true;
738
+ chunks.length = 0;
739
+ args.onOversized(file.name);
740
+ return;
741
+ }
742
+ // fflate may reuse the chunk buffer — copy before retaining.
743
+ chunks.push(chunk.slice());
744
+ if (final)
745
+ out.set(file.name, new TextDecoder().decode(concatChunks(chunks, total)));
746
+ };
747
+ file.start();
748
+ };
749
+ pushArchive(unzip, args.bytes);
750
+ return out;
751
+ }
752
+ function concatChunks(chunks, total) {
753
+ if (chunks.length === 1)
754
+ return chunks[0];
755
+ const out = new Uint8Array(total);
756
+ let offset = 0;
757
+ for (const c of chunks) {
758
+ out.set(c, offset);
759
+ offset += c.length;
760
+ }
761
+ return out;
762
+ }
763
+ /** Stream the archive once, piping each worksheet entry selected by `want()`
764
+ * through `sinkFor(name)` chunk-by-chunk (the worksheet is never materialized
765
+ * whole). Synchronous (UnzipInflate). */
766
+ function streamWorksheets(args) {
767
+ const unzip = new Unzip();
768
+ unzip.register(UnzipInflate);
769
+ unzip.onfile = (file) => {
770
+ if (!args.want(file.name))
771
+ return;
772
+ const sink = args.sinkFor(file.name);
773
+ if (!sink)
774
+ return;
775
+ const decoder = new TextDecoder();
776
+ file.ondata = (err, chunk, final) => {
777
+ if (err)
778
+ return;
779
+ sink.write(decoder.decode(chunk, { stream: !final }));
780
+ if (final)
781
+ sink.finalize();
782
+ };
783
+ file.start();
784
+ };
785
+ pushArchive(unzip, args.bytes);
786
+ }
787
+ function resolveZipPath(baseDir, target) {
788
+ if (target.startsWith("/"))
789
+ return target.slice(1);
790
+ return path.posix.normalize(path.posix.join(baseDir, target));
791
+ }
792
+ function parseRels(xml) {
793
+ const rels = [];
794
+ const parser = new SaxesParser();
795
+ parser.on("opentag", (node) => {
796
+ if (node.name === "Relationship") {
797
+ const a = attrsOf(node);
798
+ rels.push({ id: a.Id ?? "", type: a.Type ?? "", target: a.Target ?? "", targetMode: a.TargetMode ?? "" });
799
+ }
800
+ });
801
+ parser.write(xml).close();
802
+ return rels;
803
+ }
804
+ function parseWorkbook(xml) {
805
+ const sheets = [];
806
+ const definedNames = [];
807
+ const parser = new SaxesParser();
808
+ let inDefinedName = false;
809
+ let dnName = "";
810
+ let dnLocal;
811
+ let dnText = "";
812
+ parser.on("opentag", (node) => {
813
+ const a = attrsOf(node);
814
+ if (node.name === "sheet") {
815
+ const state = a.state ?? "visible";
816
+ sheets.push({
817
+ name: a.name ?? "",
818
+ rid: a["r:id"] ?? a.id ?? "",
819
+ hidden: state === "hidden" || state === "veryHidden",
820
+ });
821
+ }
822
+ else if (node.name === "definedName") {
823
+ inDefinedName = true;
824
+ dnName = a.name ?? "";
825
+ dnLocal = a.localSheetId;
826
+ dnText = "";
827
+ }
828
+ });
829
+ parser.on("text", (t) => {
830
+ if (inDefinedName)
831
+ dnText += t;
832
+ });
833
+ parser.on("closetag", (node) => {
834
+ if (node.name === "definedName") {
835
+ definedNames.push({ name: dnName, refersTo: dnText.trim(), localSheetId: dnLocal });
836
+ inDefinedName = false;
837
+ }
838
+ });
839
+ parser.write(xml).close();
840
+ return { sheets, definedNames };
841
+ }
842
+ /** Shared string table (cells of type `s` reference it by index). Rich-text runs
843
+ * are concatenated. Bounded only by the per-entry byte budget upstream. */
844
+ function parseSharedStrings(xml) {
845
+ const strings = [];
846
+ const parser = new SaxesParser();
847
+ let inSi = false;
848
+ let inT = false;
849
+ let current = "";
850
+ parser.on("opentag", (node) => {
851
+ if (node.name === "si") {
852
+ inSi = true;
853
+ current = "";
854
+ }
855
+ else if (node.name === "t" && inSi) {
856
+ inT = true;
857
+ }
858
+ });
859
+ parser.on("text", (t) => {
860
+ if (inT)
861
+ current += t;
862
+ });
863
+ parser.on("closetag", (node) => {
864
+ if (node.name === "t")
865
+ inT = false;
866
+ else if (node.name === "si") {
867
+ strings.push(current);
868
+ inSi = false;
869
+ }
870
+ });
871
+ parser.write(xml).close();
872
+ return strings;
873
+ }
874
+ function parseTable(xml) {
875
+ let result = null;
876
+ const parser = new SaxesParser();
877
+ parser.on("opentag", (node) => {
878
+ if (node.name === "table" && !result) {
879
+ const a = attrsOf(node);
880
+ result = { name: a.name ?? "", ref: a.ref ?? "" };
881
+ }
882
+ });
883
+ parser.write(xml).close();
884
+ return result;
885
+ }
886
+ // Excel stores dates as numeric serials; the date-ness is in the cell's number
887
+ // format (style), not the value. Builtin date numFmt IDs (date / datetime only;
888
+ // time-only 18-21,45-47 are excluded so a time serial isn't rendered as a date).
889
+ const BUILTIN_DATE_NUMFMT_IDS = new Set([14, 15, 16, 17, 22]);
890
+ /** A custom format is date-ish if it carries a year or day token (`m` alone is
891
+ * ambiguous month/minute). Bracketed tokens ([Red], [$-409], [h]), quoted
892
+ * literals ("text"), and escaped chars are stripped first so a color/currency
893
+ * format like `[Red]#,##0` isn't mistaken for a date via the `d` in "Red". */
894
+ function isDateFormatCode(code) {
895
+ const stripped = code
896
+ .replace(/\[[^\]]*\]/g, "")
897
+ .replace(/"[^"]*"/g, "")
898
+ .replace(/\\./g, "");
899
+ return /[yd]/i.test(stripped);
900
+ }
901
+ /** Normalize a numFmt formatCode into a deterministic, DOMAIN-AGNOSTIC, source-safe identity: the
902
+ * display GRAMMAR only. Strips locale/color/elapsed brackets (`[$-409]` `[Red]` `[h]`), QUOTED
903
+ * LITERALS (`"text"` — sanitizing any domain text so it never reaches a prompt), and escaped chars
904
+ * (`\.`); collapses whitespace and lowercases. The code NEVER names a format (it does not decide
905
+ * "US vs UK date") — it only carries the sanitized grammar so a downstream LLM can name a format
906
+ * CHANGE at runtime (owner 2026-06-27). `m/d/yyyy` and `d/m/yyyy` stay DISTINCT identities (the
907
+ * boundary is still caught) without any naming. Reuses the same strip rules as isDateFormatCode. */
908
+ function normalizeFormatCode(code) {
909
+ return code
910
+ .replace(/\[[^\]]*\]/g, "")
911
+ .replace(/"[^"]*"/g, "")
912
+ .replace(/\\./g, "")
913
+ .replace(/\s+/g, "")
914
+ .toLowerCase();
915
+ }
916
+ /** Parse styles.xml → (1) the set of cellXfs indexes (a cell's `s` attribute) whose number format
917
+ * is a date, so date-styled numeric cells can be typed as dates; and (2) `xfFormatIdentity`: each
918
+ * cellXf index → its domain-agnostic format-identity (sanitized display formatCode for custom
919
+ * formats; `b<numFmtId>` for builtin/General — an opaque key, NOT a naming table). The value-tile
920
+ * projection uses (2) to detect DISPLAY-format boundaries without the code ever naming the format. */
921
+ function parseStyles(xml) {
922
+ const dateNumFmtIds = new Set(BUILTIN_DATE_NUMFMT_IDS);
923
+ // Custom numFmt definitions (numFmtId → raw formatCode) precede cellXfs in OOXML, so they are
924
+ // fully known before the cellXfs loop resolves each xf's identity.
925
+ const customFormatCode = new Map();
926
+ const dateXfIndexes = new Set();
927
+ const xfFormatIdentity = new Map();
928
+ let inCellXfs = false;
929
+ let xfIndex = 0;
930
+ const parser = new SaxesParser();
931
+ parser.on("opentag", (node) => {
932
+ const a = attrsOf(node);
933
+ if (node.name === "numFmt") {
934
+ const id = parseInt(a.numFmtId ?? "", 10);
935
+ if (Number.isInteger(id) && a.formatCode) {
936
+ customFormatCode.set(id, a.formatCode);
937
+ if (isDateFormatCode(a.formatCode))
938
+ dateNumFmtIds.add(id);
939
+ }
940
+ }
941
+ else if (node.name === "cellXfs") {
942
+ inCellXfs = true; // the `s` attribute indexes into cellXfs, not cellStyleXfs
943
+ xfIndex = 0;
944
+ }
945
+ else if (node.name === "xf" && inCellXfs) {
946
+ const id = parseInt(a.numFmtId ?? "0", 10);
947
+ if (dateNumFmtIds.has(id))
948
+ dateXfIndexes.add(xfIndex);
949
+ const custom = customFormatCode.get(id);
950
+ // custom → sanitized grammar; builtin (incl. General id 0) → opaque `b<id>` (no naming).
951
+ xfFormatIdentity.set(xfIndex, custom !== undefined ? normalizeFormatCode(custom) : `b${id}`);
952
+ xfIndex += 1;
953
+ }
954
+ });
955
+ parser.on("closetag", (node) => {
956
+ if (node.name === "cellXfs")
957
+ inCellXfs = false;
958
+ });
959
+ parser.write(xml).close();
960
+ return { dateXfIndexes, xfFormatIdentity };
961
+ }
962
+ /** Excel 1900-system serial → ISO date (YYYY-MM-DD). 25569 = serial of
963
+ * 1970-01-01; correct for dates from 1900-03-01 on (covers real data). */
964
+ function excelSerialToISODate(serial) {
965
+ if (!Number.isFinite(serial))
966
+ return null;
967
+ const d = new Date(Math.round((serial - 25569) * 86400000));
968
+ if (Number.isNaN(d.getTime()))
969
+ return null;
970
+ return d.toISOString().slice(0, 10);
971
+ }
972
+ /** Parse a pivotTableN.xml: the field LAYOUT (which cache-field index sits on
973
+ * rows / columns / pages / data), not any cached data. */
974
+ function parsePivotTable(xml) {
975
+ let name = "";
976
+ let location = "";
977
+ let sawRoot = false;
978
+ const rowFieldIdx = [];
979
+ const colFieldIdx = [];
980
+ const pageFieldIdx = [];
981
+ const dataFields = [];
982
+ let section = null;
983
+ const parser = new SaxesParser();
984
+ parser.on("opentag", (node) => {
985
+ const a = attrsOf(node);
986
+ switch (node.name) {
987
+ case "pivotTableDefinition":
988
+ name = a.name ?? "";
989
+ sawRoot = true;
990
+ break;
991
+ case "location":
992
+ if (a.ref)
993
+ location = a.ref;
994
+ break;
995
+ case "rowFields":
996
+ section = "row";
997
+ break;
998
+ case "colFields":
999
+ section = "col";
1000
+ break;
1001
+ case "pageFields":
1002
+ section = "page";
1003
+ break;
1004
+ case "dataFields":
1005
+ section = "data";
1006
+ break;
1007
+ case "field": {
1008
+ const x = parseInt(a.x ?? "", 10);
1009
+ if (Number.isInteger(x) && x >= 0) {
1010
+ if (section === "row")
1011
+ rowFieldIdx.push(x);
1012
+ else if (section === "col")
1013
+ colFieldIdx.push(x);
1014
+ }
1015
+ break;
1016
+ }
1017
+ case "pageField": {
1018
+ const f = parseInt(a.fld ?? "", 10);
1019
+ if (Number.isInteger(f) && f >= 0)
1020
+ pageFieldIdx.push(f);
1021
+ break;
1022
+ }
1023
+ case "dataField": {
1024
+ const f = parseInt(a.fld ?? "", 10);
1025
+ if (Number.isInteger(f))
1026
+ dataFields.push({ fld: f, name: a.name ?? "" });
1027
+ break;
1028
+ }
1029
+ default:
1030
+ break;
1031
+ }
1032
+ });
1033
+ parser.on("closetag", (node) => {
1034
+ if (node.name === "rowFields" ||
1035
+ node.name === "colFields" ||
1036
+ node.name === "pageFields" ||
1037
+ node.name === "dataFields") {
1038
+ section = null;
1039
+ }
1040
+ });
1041
+ parser.write(xml).close();
1042
+ return sawRoot ? { name, location, rowFieldIdx, colFieldIdx, pageFieldIdx, dataFields } : null;
1043
+ }
1044
+ /** Parse a pivotCacheDefinition: the source range/sheet and the cache field NAMES
1045
+ * (indexed; pivot field placements reference them). Cache RECORDS are never read. */
1046
+ function parsePivotCacheDefinition(xml) {
1047
+ let sourceSheet = null;
1048
+ let sourceRef = null;
1049
+ const fieldNames = [];
1050
+ const parser = new SaxesParser();
1051
+ parser.on("opentag", (node) => {
1052
+ const a = attrsOf(node);
1053
+ if (node.name === "worksheetSource") {
1054
+ sourceSheet = a.sheet ?? null;
1055
+ sourceRef = a.ref ?? a.name ?? null;
1056
+ }
1057
+ else if (node.name === "cacheField") {
1058
+ fieldNames.push(a.name ?? "");
1059
+ }
1060
+ });
1061
+ parser.write(xml).close();
1062
+ return { sourceSheet, sourceRef, fieldNames };
1063
+ }
1064
+ /** Maximum characters kept from a single validation bounds expression: a list source can
1065
+ * be arbitrarily long, but the prompt only needs enough to audit the constraint shape. */
1066
+ const XLSX_VALIDATION_FORMULA_CHARS = 200;
1067
+ // Length bound for a validation sqref before whitespace-splitting it into sub-ranges, so a
1068
+ // crafted huge whitespace-union cannot allocate an unbounded array (Codex round3 #6). A real
1069
+ // sqref references a handful of ranges; this is a generous safety ceiling.
1070
+ const XLSX_VALIDATION_SQREF_CHARS = 8192;
1071
+ /** Build an auditable data-validation rule summary from the captured constraint fields.
1072
+ * formula1/formula2 are the rule BOUNDS (list source, min/max, date) — the constraint
1073
+ * definition, not raw cell data — so rendering them lets the reviewer verify
1074
+ * data_validation_coverage instead of seeing only the validation kind. Long expressions
1075
+ * are bounded with an explicit ellipsis so the summary never balloons. */
1076
+ function buildValidationRuleSummary(rule) {
1077
+ const clip = (text) => text.length > XLSX_VALIDATION_FORMULA_CHARS
1078
+ ? `${text.slice(0, XLSX_VALIDATION_FORMULA_CHARS)}…`
1079
+ : text;
1080
+ // For a type=list INLINE formula1, do NOT echo the declared values here: they are carried by
1081
+ // the bounded `members` field (VALIDATION_MEMBER_* caps). Echoing them in rule_summary would be
1082
+ // a second, differently-bounded value channel that leaks over-cap declared labels into the
1083
+ // prompt/admission artifact when `members` is suppressed (Codex round1 #2). Summarize as a
1084
+ // member count (reusing the bounded parse, never a fresh full split); a range-ref or non-list
1085
+ // formula1 keeps the clipped display.
1086
+ const renderFormula1 = (f1) => {
1087
+ if (rule.type === "list" && f1.trim().startsWith('"')) {
1088
+ const m = rule.members ?? parseInlineListMembers(f1);
1089
+ return m.members ? `list(${m.members.length} members)` : "list(capped)";
1090
+ }
1091
+ return clip(f1);
1092
+ };
1093
+ const parts = [`type=${rule.type || "any"}`];
1094
+ if (rule.operator)
1095
+ parts.push(`operator=${rule.operator}`);
1096
+ if (rule.formula1)
1097
+ parts.push(`formula1=${renderFormula1(rule.formula1)}`);
1098
+ if (rule.formula2)
1099
+ parts.push(`formula2=${clip(rule.formula2)}`);
1100
+ return parts.join("; ");
1101
+ }
1102
+ /** design-C declared enum members from a dataValidation formula1 (type=list only).
1103
+ * Returns members ONLY when formula1 is an INLINE quoted literal (`"a,b,c"`): strip the
1104
+ * outer quotes, split on the list separator (`,`), trim. A RANGE-REF formula1 (members live
1105
+ * in other cells, e.g. `Lists!$A$1:$A$5`) is UNRESOLVED → `{ members: undefined,
1106
+ * truncated: true }`. Over the count cap, or any member over the char cap → also truncated
1107
+ * with members absent (a long "member" signals it is not an enum). Never reads observed
1108
+ * cell values. */
1109
+ function parseInlineListMembers(formula1) {
1110
+ const trimmed = formula1.trim();
1111
+ // Inline literal: starts (and, well-formed, ends) with a double quote. Anything else
1112
+ // (a range ref, a defined name) is unresolved here.
1113
+ if (!trimmed.startsWith('"'))
1114
+ return { truncated: true };
1115
+ // Strip the outer quotes (drop a trailing quote if present).
1116
+ const inner = trimmed.endsWith('"') && trimmed.length >= 2
1117
+ ? trimmed.slice(1, -1)
1118
+ : trimmed.slice(1);
1119
+ // Bound the work BEFORE splitting (Codex round2 #4): a valid in-cap list is at most
1120
+ // COUNT_CAP × (CHAR_CAP + 1) chars, so anything longer cannot be in-cap — return truncated
1121
+ // without materializing every member of a crafted/huge streamed inline list.
1122
+ if (inner.length > VALIDATION_MEMBER_COUNT_CAP * (VALIDATION_MEMBER_CHAR_CAP + 1)) {
1123
+ return { truncated: true };
1124
+ }
1125
+ const members = inner.split(",").map((m) => m.trim());
1126
+ if (members.length > VALIDATION_MEMBER_COUNT_CAP)
1127
+ return { truncated: true };
1128
+ if (members.some((m) => m.length > VALIDATION_MEMBER_CHAR_CAP))
1129
+ return { truncated: true };
1130
+ return { members, truncated: false };
1131
+ }
1132
+ /** design-C: the origin-NORMALIZED profiled column indices a validation sqref covers.
1133
+ * Split sqref on whitespace (a multi-range sqref like "A1:A5 C1:C5"), parse each sub-range
1134
+ * with parseDimension (NOT parseCellRef — sqref entries are ranges), union the column spans,
1135
+ * normalize each by `- dimStartCol` (same frame as the cell-column normalization), and keep
1136
+ * only `0 <= c < profiledCols`. A whole-column ref (`B:B` / `$B:$D`) fails parseDimension (no
1137
+ * row), so its column-letter span is parsed directly and mapped to ITS columns only — not the
1138
+ * whole sheet (Codex round2 #1). Result is sorted ascending and de-duplicated. The sqref is
1139
+ * length-bounded and iteration stops once every profiled column is covered, so a crafted huge
1140
+ * whitespace-union cannot consume unbounded memory/CPU (Codex round3 #6). */
1141
+ function parseSqrefColumns(sqref, dimStartCol, profiledCols) {
1142
+ const cols = new Set();
1143
+ // Add an ABSOLUTE column span, normalized to the used-range origin and CLAMPED to the profiled
1144
+ // window [dimStartCol, dimStartCol + profiledCols) before iterating — so a crafted huge range
1145
+ // (e.g. A1:ZZZZZZ1) cannot make this walk millions of columns (Codex #4).
1146
+ const addAbsSpan = (startAbs, endAbs) => {
1147
+ const lo = Math.max(startAbs, dimStartCol);
1148
+ const hi = Math.min(endAbs, dimStartCol + profiledCols - 1);
1149
+ for (let abs = lo; abs <= hi; abs += 1)
1150
+ cols.add(abs - dimStartCol);
1151
+ };
1152
+ // Bound before splitting: cap the sqref length (a pathological union can be huge) and stop once
1153
+ // every profiled column is already covered — neither can add more (Codex round3 #6).
1154
+ const bounded = sqref.length > XLSX_VALIDATION_SQREF_CHARS
1155
+ ? sqref.slice(0, XLSX_VALIDATION_SQREF_CHARS)
1156
+ : sqref;
1157
+ for (const sub of bounded.split(/\s+/)) {
1158
+ if (cols.size >= profiledCols)
1159
+ break;
1160
+ const ref = sub.trim();
1161
+ if (ref.length === 0)
1162
+ continue;
1163
+ const d = parseDimension(ref);
1164
+ if (d) {
1165
+ addAbsSpan(d.startCol, d.startCol + d.dims.cols - 1);
1166
+ continue;
1167
+ }
1168
+ // Whole-column range (B:B / $B:$D): no row component, so parseDimension declined. Map it to
1169
+ // ITS column span (not the whole sheet) so the declared enum attaches only to those columns
1170
+ // (Codex #1).
1171
+ const m = /^\$?([A-Z]+):\$?([A-Z]+)$/.exec(ref);
1172
+ if (m) {
1173
+ const a = columnLettersToIndex(m[1]);
1174
+ const b = columnLettersToIndex(m[2]);
1175
+ addAbsSpan(Math.min(a, b), Math.max(a, b));
1176
+ }
1177
+ }
1178
+ return [...cols].sort((a, b) => a - b);
1179
+ }
1180
+ /** A streaming worksheet parser: pipe decompressed chunks through `write`, then
1181
+ * `finalize`, then read `getResult`. The worksheet is never materialized whole;
1182
+ * the per-sheet cell scan is bounded by `caps` (cell VALUE work stops at the row
1183
+ * cap, structural parts after <sheetData> are still captured). */
1184
+ function createWorksheetParser(args) {
1185
+ const { sharedStrings, sheetName, caps, dateXfIndexes, xfFormatIdentity } = args;
1186
+ const captureFormats = xfFormatIdentity !== undefined;
1187
+ const merged_ranges = [];
1188
+ // design-C: capture each validation's RAW fields during streaming; resolve
1189
+ // validation_type / members / applies_to_columns at getResult time, when dimStartCol is
1190
+ // final and the profiled-column count is known (so normalization is in the right frame).
1191
+ // design-C (Codex round2 #5): members + the clipped rule_summary are computed at CAPTURE from
1192
+ // the full formula text (which is transient, one validation at a time), so this never retains
1193
+ // raw formula1/formula2 for up to XLSX_DATAVALIDATION_CAP entries until getResult.
1194
+ const rawValidations = [];
1195
+ // Tier-1 exact-text formula dedup, accumulated at extraction time. The key is the
1196
+ // resolved formula text (followers resolve to the master verbatim), so a fill-down
1197
+ // collapses to one entry. occurrence_count counts every cell of a retained pattern;
1198
+ // formulaCellsTotal counts EVERY formula cell (cap-independent). XLSX_FORMULA_CAP is
1199
+ // repurposed as a DISTINCT-pattern cap; dropping a new pattern at the cap sets
1200
+ // formulaPatternsCapped (an honest lower-bound flag), not capsHit.
1201
+ const formulaPatterns = new Map();
1202
+ let formulaCellsTotal = 0;
1203
+ let formulaPatternsCapped = false;
1204
+ const error_cells = [];
1205
+ const rows = [];
1206
+ // Per-cell cellXf index, index-aligned with `rows` (only populated when captureFormats). A cell's
1207
+ // domain-agnostic format-identity is recovered later via xfFormatIdentity.get(index).
1208
+ const formatRows = [];
1209
+ let protectedSheet = false;
1210
+ let declaredDims = null;
1211
+ let declaredUsedRange = null;
1212
+ let dimStartCol = 0; // used-range origin column (0 = A); normalizes cell columns
1213
+ let firstRowNum = -1; // r of the first <row> seen; anchors sparse-gap padding
1214
+ let maxRow = 0;
1215
+ let maxCol = 0;
1216
+ let rowsTruncated = false;
1217
+ let capsHit = false;
1218
+ // Shared formulas: only the master cell carries text (`<f t="shared" si="N">…`),
1219
+ // followers are empty (`<f t="shared" si="N"/>`). Resolve followers to the master.
1220
+ const sharedFormulas = new Map();
1221
+ let cellHasFormula = false;
1222
+ let fShared = false;
1223
+ let fSi = "";
1224
+ let curRow = null;
1225
+ let curFormatRow = null;
1226
+ let cellRef = "";
1227
+ let cellType = "";
1228
+ let cellStyle = -1;
1229
+ let cellCol = 0;
1230
+ let inV = false;
1231
+ let inF = false;
1232
+ let inIs = false;
1233
+ let inIsT = false;
1234
+ let vText = "";
1235
+ let fText = "";
1236
+ let isText = "";
1237
+ // dataValidation rule capture: a validation's BOUNDS live in its formula1/formula2
1238
+ // children (a list source, a min/max, a date) and its comparison in the `operator`
1239
+ // attribute — the constraint DEFINITION a reviewer must audit, not raw cell data. We
1240
+ // accumulate them across the open/text/close of one <dataValidation> and emit on close
1241
+ // so data_validation_coverage is backed by the actual rule, not just `type=`.
1242
+ let dvActive = false;
1243
+ let dvType = "";
1244
+ let dvOperator = "";
1245
+ let dvSqref = "";
1246
+ let dvFormula1 = "";
1247
+ let dvFormula2 = "";
1248
+ let inDvF1 = false;
1249
+ let inDvF2 = false;
1250
+ const parser = new SaxesParser();
1251
+ parser.on("opentag", (node) => {
1252
+ const a = attrsOf(node);
1253
+ switch (node.name) {
1254
+ case "dimension": {
1255
+ if (a.ref) {
1256
+ const d = parseDimension(a.ref);
1257
+ if (d) {
1258
+ declaredDims = d.dims;
1259
+ declaredUsedRange = d.usedRange;
1260
+ dimStartCol = d.startCol;
1261
+ }
1262
+ }
1263
+ break;
1264
+ }
1265
+ case "sheetProtection":
1266
+ protectedSheet = true;
1267
+ break;
1268
+ case "mergeCell":
1269
+ if (a.ref) {
1270
+ if (merged_ranges.length < XLSX_MERGE_CAP)
1271
+ merged_ranges.push({ sheet: sheetName, range: a.ref });
1272
+ else
1273
+ capsHit = true;
1274
+ }
1275
+ break;
1276
+ case "dataValidation":
1277
+ // Begin a validation; the rule_summary is built on close from type + operator +
1278
+ // formula1/formula2 (the bounds), so the reviewer sees the constraint, not just
1279
+ // its kind. self-closing (<dataValidation .../>) still fires a closetag below.
1280
+ dvActive = true;
1281
+ dvType = a.type ?? "any";
1282
+ dvOperator = a.operator ?? "";
1283
+ dvSqref = a.sqref ?? "";
1284
+ dvFormula1 = "";
1285
+ dvFormula2 = "";
1286
+ break;
1287
+ case "formula1":
1288
+ if (dvActive) {
1289
+ inDvF1 = true;
1290
+ dvFormula1 = "";
1291
+ }
1292
+ break;
1293
+ case "formula2":
1294
+ if (dvActive) {
1295
+ inDvF2 = true;
1296
+ dvFormula2 = "";
1297
+ }
1298
+ break;
1299
+ case "row": {
1300
+ // Honor the row's `r` index so omitted empty rows leave real gaps —
1301
+ // otherwise sparse sheets collapse and inflate completeness ratios. Gaps
1302
+ // are padded as empty rows, bounded by the row cap.
1303
+ const rowNum = parseInt(a.r ?? "0", 10);
1304
+ if (rowNum > 0) {
1305
+ if (firstRowNum < 0)
1306
+ firstRowNum = rowNum;
1307
+ const targetIndex = rowNum - firstRowNum;
1308
+ while (rows.length < targetIndex && rows.length < caps.max_rows_scanned_per_sheet) {
1309
+ rows.push([]);
1310
+ if (captureFormats)
1311
+ formatRows.push([]); // keep formatRows index-aligned with rows
1312
+ }
1313
+ }
1314
+ curRow = rows.length >= caps.max_rows_scanned_per_sheet ? null : [];
1315
+ curFormatRow = curRow === null ? null : captureFormats ? [] : null;
1316
+ if (curRow === null)
1317
+ rowsTruncated = true;
1318
+ break;
1319
+ }
1320
+ case "c": {
1321
+ cellRef = a.r ?? "";
1322
+ cellType = a.t ?? "";
1323
+ cellStyle = a.s !== undefined ? parseInt(a.s, 10) : -1;
1324
+ const parsed = cellRef ? parseCellRef(cellRef) : null;
1325
+ // Normalize to the used-range origin so an offset sheet (e.g. B2:D10)
1326
+ // doesn't gain phantom leading empty columns.
1327
+ cellCol = parsed ? Math.max(0, parsed.col - dimStartCol) : curRow ? curRow.length : 0;
1328
+ if (parsed) {
1329
+ maxRow = Math.max(maxRow, parsed.row);
1330
+ maxCol = Math.max(maxCol, parsed.col + 1);
1331
+ }
1332
+ vText = "";
1333
+ fText = "";
1334
+ isText = "";
1335
+ cellHasFormula = false;
1336
+ fShared = false;
1337
+ fSi = "";
1338
+ break;
1339
+ }
1340
+ case "v":
1341
+ inV = true;
1342
+ vText = "";
1343
+ break;
1344
+ case "f":
1345
+ inF = true;
1346
+ fText = "";
1347
+ cellHasFormula = true;
1348
+ fShared = a.t === "shared";
1349
+ fSi = a.si ?? "";
1350
+ break;
1351
+ case "is":
1352
+ inIs = true;
1353
+ isText = "";
1354
+ break;
1355
+ case "t":
1356
+ if (inIs)
1357
+ inIsT = true;
1358
+ break;
1359
+ default:
1360
+ break;
1361
+ }
1362
+ });
1363
+ parser.on("text", (t) => {
1364
+ if (inV)
1365
+ vText += t;
1366
+ else if (inF)
1367
+ fText += t;
1368
+ else if (inIsT)
1369
+ isText += t;
1370
+ else if (inDvF1)
1371
+ dvFormula1 += t;
1372
+ else if (inDvF2)
1373
+ dvFormula2 += t;
1374
+ });
1375
+ parser.on("closetag", (node) => {
1376
+ switch (node.name) {
1377
+ case "v":
1378
+ inV = false;
1379
+ break;
1380
+ case "f":
1381
+ inF = false;
1382
+ break;
1383
+ case "is":
1384
+ inIs = false;
1385
+ break;
1386
+ case "formula1":
1387
+ inDvF1 = false;
1388
+ break;
1389
+ case "formula2":
1390
+ inDvF2 = false;
1391
+ break;
1392
+ case "dataValidation":
1393
+ if (dvActive) {
1394
+ dvActive = false;
1395
+ if (rawValidations.length < XLSX_DATAVALIDATION_CAP) {
1396
+ // Parse declared enum members from the FULL formula1 now (bounded by
1397
+ // parseInlineListMembers) and build the clipped rule_summary here, retaining only
1398
+ // bounded results — the raw formula text is not accumulated (Codex round2 #5).
1399
+ const parsedMembers = dvType === "list" ? parseInlineListMembers(dvFormula1) : { truncated: true };
1400
+ rawValidations.push({
1401
+ sqref: dvSqref,
1402
+ type: dvType,
1403
+ ...(parsedMembers.members !== undefined
1404
+ ? { members: parsedMembers.members }
1405
+ : {}),
1406
+ membersTruncated: parsedMembers.truncated,
1407
+ rule_summary: buildValidationRuleSummary({
1408
+ type: dvType,
1409
+ operator: dvOperator,
1410
+ formula1: dvFormula1,
1411
+ formula2: dvFormula2,
1412
+ members: parsedMembers,
1413
+ }),
1414
+ });
1415
+ }
1416
+ else {
1417
+ capsHit = true;
1418
+ }
1419
+ }
1420
+ break;
1421
+ case "c": {
1422
+ // Formula + error are structural (kept regardless of the row cap, bounded
1423
+ // by their own caps). The cell's grid value is the cached <v> result.
1424
+ // A shared-formula master stores its text; a follower (empty <f>) reuses it
1425
+ // so filled-down formulas are not undercounted.
1426
+ let formulaText = fText;
1427
+ if (fShared && fSi) {
1428
+ if (fText.length > 0)
1429
+ sharedFormulas.set(fSi, fText);
1430
+ else
1431
+ formulaText = sharedFormulas.get(fSi) ?? "";
1432
+ }
1433
+ if (cellHasFormula) {
1434
+ // tier-1 exact-text dedup, keyed on the resolved formula text (followers
1435
+ // resolve to the master verbatim, so fill-downs collapse to one entry).
1436
+ formulaCellsTotal += 1; // counts every formula cell, cap-independent.
1437
+ const existing = formulaPatterns.get(formulaText);
1438
+ if (existing) {
1439
+ existing.occurrence_count += 1;
1440
+ // Single-column attribution (#3): a cell in a different column demotes the pattern to
1441
+ // multi-column (null), so it can never mark a column as a single uniform formula.
1442
+ if (existing.single_col !== null && existing.single_col !== cellCol)
1443
+ existing.single_col = null;
1444
+ if (existing.applied_ranges.length < XLSX_FORMULA_APPLIED_RANGE_CAP) {
1445
+ existing.applied_ranges.push(cellRef);
1446
+ }
1447
+ for (const ref of extractCrossSheetRefs(formulaText, sheetName)) {
1448
+ if (!existing.cross_sheet_refs.includes(ref))
1449
+ existing.cross_sheet_refs.push(ref);
1450
+ }
1451
+ }
1452
+ else if (formulaPatterns.size < XLSX_FORMULA_CAP) {
1453
+ formulaPatterns.set(formulaText, {
1454
+ sample_cell: cellRef,
1455
+ occurrence_count: 1,
1456
+ applied_ranges: [cellRef],
1457
+ cross_sheet_refs: extractCrossSheetRefs(formulaText, sheetName),
1458
+ single_col: cellCol,
1459
+ });
1460
+ }
1461
+ else {
1462
+ // Distinct-pattern cap hit: the cell is still counted in formulaCellsTotal,
1463
+ // but the new distinct pattern is dropped — an honest lower bound.
1464
+ formulaPatternsCapped = true;
1465
+ }
1466
+ }
1467
+ if (cellType === "e") {
1468
+ if (error_cells.length < XLSX_ERROR_CELL_CAP) {
1469
+ error_cells.push({ sheet: sheetName, cell: cellRef, token: vText });
1470
+ }
1471
+ else {
1472
+ capsHit = true;
1473
+ }
1474
+ }
1475
+ if (curRow) {
1476
+ let value;
1477
+ if (cellType === "s")
1478
+ value = sharedStrings[parseInt(vText, 10)] ?? "";
1479
+ else if (cellType === "inlineStr")
1480
+ value = isText;
1481
+ else if (cellType === "b")
1482
+ value = vText === "1" ? "true" : "false";
1483
+ else if ((cellType === "" || cellType === "n") &&
1484
+ vText.length > 0 &&
1485
+ dateXfIndexes.has(cellStyle)) {
1486
+ // Numeric serial with a date number format → emit the ISO date so the
1487
+ // column is typed as a date, not a number (Codex P2).
1488
+ value = excelSerialToISODate(Number(vText)) ?? vText;
1489
+ }
1490
+ else
1491
+ value = vText; // str / e / n / default number
1492
+ if (cellCol >= 0) {
1493
+ while (curRow.length < cellCol)
1494
+ curRow.push("");
1495
+ curRow[cellCol] = value;
1496
+ if (curFormatRow) {
1497
+ while (curFormatRow.length < cellCol)
1498
+ curFormatRow.push(-1);
1499
+ curFormatRow[cellCol] = cellStyle; // cellXf index; -1 = no style (General)
1500
+ }
1501
+ }
1502
+ }
1503
+ break;
1504
+ }
1505
+ case "t":
1506
+ if (inIs)
1507
+ inIsT = false;
1508
+ break;
1509
+ case "row":
1510
+ if (curRow) {
1511
+ rows.push(curRow);
1512
+ if (captureFormats)
1513
+ formatRows.push(curFormatRow ?? []);
1514
+ curRow = null;
1515
+ curFormatRow = null;
1516
+ }
1517
+ break;
1518
+ default:
1519
+ break;
1520
+ }
1521
+ });
1522
+ return {
1523
+ write: (text) => {
1524
+ parser.write(text);
1525
+ },
1526
+ finalize: () => {
1527
+ parser.close();
1528
+ },
1529
+ getResult: () => {
1530
+ // design-C authority resolution: members + rule_summary were already resolved at capture
1531
+ // (Codex round2 #5); now that dimStartCol and the full grid are final, compute the
1532
+ // profiled-column count (same frame as profileSheetRows) and the normalized covered columns.
1533
+ const colCount = rows.reduce((m, r) => Math.max(m, r.length), 0);
1534
+ const profiledCols = Math.min(colCount, caps.max_columns_profiled);
1535
+ const data_validations = rawValidations.map((v) => ({
1536
+ sheet: sheetName,
1537
+ range: v.sqref,
1538
+ rule_summary: v.rule_summary,
1539
+ validation_type: v.type,
1540
+ ...(v.members !== undefined ? { members: v.members } : {}),
1541
+ members_truncated: v.membersTruncated,
1542
+ applies_to_columns: parseSqrefColumns(v.sqref, dimStartCol, profiledCols),
1543
+ }));
1544
+ return {
1545
+ dimensions: declaredDims ?? { rows: maxRow, cols: maxCol },
1546
+ used_range: declaredUsedRange,
1547
+ protected: protectedSheet,
1548
+ merged_ranges,
1549
+ data_validations,
1550
+ // Materialize each entry with its key (the formula text) as `pattern`.
1551
+ formula_patterns: [...formulaPatterns.entries()].map(([pattern, entry]) => ({
1552
+ pattern,
1553
+ sample_cell: entry.sample_cell,
1554
+ occurrence_count: entry.occurrence_count,
1555
+ applied_ranges: entry.applied_ranges,
1556
+ cross_sheet_refs: entry.cross_sheet_refs,
1557
+ applies_to_single_column: entry.single_col,
1558
+ })),
1559
+ formula_cells_total: formulaCellsTotal,
1560
+ formula_patterns_capped: formulaPatternsCapped,
1561
+ error_cells,
1562
+ rows,
1563
+ ...(captureFormats ? { formatRows } : {}),
1564
+ rows_truncated: rowsTruncated,
1565
+ caps_hit: capsHit,
1566
+ };
1567
+ },
1568
+ };
1569
+ }
1570
+ export const DEFAULT_VALUE_TILE_OPTS = {
1571
+ window: 1024,
1572
+ segmentsPerColumnCap: 256,
1573
+ distinctPerSegmentCap: 32,
1574
+ };
1575
+ /** A normalized SERIALIZATION shape of one raw cell string (not the value, the shape). The dominant
1576
+ * shape per segment is the contradiction-ground (R8): a shape change between adjacent segments is a
1577
+ * boundary candidate. Derived purely from the value string already in `rows[][]` — so a DISPLAY-only
1578
+ * numFmt change on uniform date serials (which the pass already collapsed to ISO) is invisible here;
1579
+ * that is the separate-scan/fold exception Cut-2 characterizes, not a value-string signature. */
1580
+ function serializationShape(raw) {
1581
+ const v = raw.trim();
1582
+ if (v.length === 0)
1583
+ return "EMPTY";
1584
+ if (/^(true|false)$/i.test(v))
1585
+ return "BOOL";
1586
+ if (/^-?\d+$/.test(v))
1587
+ return "INT";
1588
+ if (/^-?\d+\.\d+$/.test(v))
1589
+ return "DEC";
1590
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v))
1591
+ return "ISO_DATE";
1592
+ if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v))
1593
+ return "ISO_DATETIME";
1594
+ if (/^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test(v))
1595
+ return "SLASH_DATE";
1596
+ if (/^\d{1,2}\.\d{1,2}\.\d{2,4}$/.test(v))
1597
+ return "DOT_DATE";
1598
+ if (/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(v))
1599
+ return "EMAIL";
1600
+ return "TEXT";
1601
+ }
1602
+ /** Pick the exact transition row inside the bracket [a.row_start, b.row_end] of two adjacent segments:
1603
+ * the last row whose shape == prev before the first row whose shape == new. Scans only the two windows
1604
+ * (bounded), so the localization resolution is row-precise regardless of `window` (P6). */
1605
+ function refineBoundary(args) {
1606
+ const { rows, col, bracketStartRow, bracketEndRow, prevSig, newSig, boundaryKind, signatureOf } = args;
1607
+ // rows[] is 0-based; sheet rows are 1-based. bracket rows are 1-based inclusive.
1608
+ let firstNew = -1;
1609
+ for (let r = bracketStartRow - 1; r <= bracketEndRow - 1 && r < rows.length; r += 1) {
1610
+ const sig = signatureOf(r, col);
1611
+ if (sig === null)
1612
+ continue;
1613
+ if (sig === newSig) {
1614
+ firstNew = r;
1615
+ break;
1616
+ }
1617
+ }
1618
+ if (firstNew < 0)
1619
+ return null;
1620
+ let lastPrev = -1;
1621
+ for (let r = firstNew - 1; r >= bracketStartRow - 1; r -= 1) {
1622
+ const sig = signatureOf(r, col);
1623
+ if (sig === null)
1624
+ continue;
1625
+ if (sig === prevSig) {
1626
+ lastPrev = r;
1627
+ break;
1628
+ }
1629
+ }
1630
+ if (lastPrev < 0)
1631
+ return null;
1632
+ return {
1633
+ boundary_kind: boundaryKind,
1634
+ prev_shape: prevSig,
1635
+ new_shape: newSig,
1636
+ last_prev_format_row: lastPrev + 1,
1637
+ first_new_format_row: firstNew + 1,
1638
+ };
1639
+ }
1640
+ /** CUT-2: project the segmented value-tile surface from the already-materialized `rows[][]` grid. PURE
1641
+ * (no source re-scan, no file read) and DETERMINISTIC (fixed iteration order). Holds only bounded
1642
+ * per-segment aggregates (P12). `rows` is `parsed.rows` from the single streaming pass — the same input
1643
+ * `profileSheetRows` consumes. */
1644
+ export function projectSegmentedValueTiles(args) {
1645
+ const { sheetName, rows, caps, opts, formatRows, xfFormatIdentity } = args;
1646
+ const captureFormats = formatRows !== undefined && xfFormatIdentity !== undefined;
1647
+ // A cell's value-string serialization shape, or null to SKIP (empty cell).
1648
+ const valueSigOf = (rowIdx0, c) => {
1649
+ const cell = (rows[rowIdx0] ?? [])[c] ?? "";
1650
+ return cell.trim().length === 0 ? null : serializationShape(cell);
1651
+ };
1652
+ // A cell's domain-agnostic format-identity, or null to SKIP (empty value cell / no style / off).
1653
+ const formatSigOf = (rowIdx0, c) => {
1654
+ if (!captureFormats)
1655
+ return null;
1656
+ const cell = (rows[rowIdx0] ?? [])[c] ?? "";
1657
+ if (cell.trim().length === 0)
1658
+ return null;
1659
+ return xfFormatIdentity.get((formatRows[rowIdx0] ?? [])[c] ?? -1) ?? null;
1660
+ };
1661
+ // Deterministic dominant key: max count, ties broken by key name ASC (shared by shape + format).
1662
+ const dominantOf = (counts) => {
1663
+ let best = null;
1664
+ let bestCount = -1;
1665
+ for (const [k, c] of Object.entries(counts).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
1666
+ if (c > bestCount) {
1667
+ bestCount = c;
1668
+ best = k;
1669
+ }
1670
+ }
1671
+ return best;
1672
+ };
1673
+ const colCount = Math.min(rows.reduce((m, r) => Math.max(m, r.length), 0), caps.max_columns_profiled);
1674
+ const window = Math.max(1, opts.window);
1675
+ const columns = [];
1676
+ let retainedSegments = 0;
1677
+ let summedSegmentDistinct = 0;
1678
+ for (let col = 0; col < colCount; col += 1) {
1679
+ const segments = [];
1680
+ let segmentsCapped = false;
1681
+ for (let segStart = 0; segStart < rows.length; segStart += window) {
1682
+ if (segments.length >= opts.segmentsPerColumnCap) {
1683
+ segmentsCapped = true;
1684
+ break;
1685
+ }
1686
+ const segEnd = Math.min(segStart + window, rows.length);
1687
+ const typeCounts = {};
1688
+ const shapeCounts = {};
1689
+ const formatCounts = {};
1690
+ const distinct = new Set();
1691
+ let distinctLowerBound = false;
1692
+ let nonEmpty = 0;
1693
+ for (let r = segStart; r < segEnd; r += 1) {
1694
+ const cell = (rows[r] ?? [])[col] ?? "";
1695
+ if (cell.trim().length === 0)
1696
+ continue;
1697
+ nonEmpty += 1;
1698
+ const t = classifyValue(cell);
1699
+ typeCounts[t] = (typeCounts[t] ?? 0) + 1;
1700
+ const s = serializationShape(cell);
1701
+ shapeCounts[s] = (shapeCounts[s] ?? 0) + 1;
1702
+ if (captureFormats) {
1703
+ const fid = formatSigOf(r, col);
1704
+ if (fid !== null)
1705
+ formatCounts[fid] = (formatCounts[fid] ?? 0) + 1;
1706
+ }
1707
+ if (distinct.size < opts.distinctPerSegmentCap)
1708
+ distinct.add(cell);
1709
+ else if (!distinct.has(cell))
1710
+ distinctLowerBound = true;
1711
+ }
1712
+ const dominant = dominantOf(shapeCounts);
1713
+ const dominantFormat = captureFormats ? dominantOf(formatCounts) : null;
1714
+ segments.push({
1715
+ row_start: segStart + 1,
1716
+ row_end: segEnd,
1717
+ non_empty: nonEmpty,
1718
+ type_counts: typeCounts,
1719
+ shape_counts: shapeCounts,
1720
+ dominant_shape: dominant,
1721
+ format_counts: formatCounts,
1722
+ dominant_format: dominantFormat,
1723
+ distinct_count: distinct.size,
1724
+ distinct_is_lower_bound: distinctLowerBound,
1725
+ });
1726
+ retainedSegments += 1;
1727
+ summedSegmentDistinct += distinct.size;
1728
+ }
1729
+ // Boundary candidates: adjacent non-empty segments whose dominant shape differs → refine to the exact
1730
+ // transition row over the two-window bracket.
1731
+ const intraTileNotes = [];
1732
+ for (let i = 1; i < segments.length; i += 1) {
1733
+ const prev = segments[i - 1];
1734
+ const cur = segments[i];
1735
+ // value-string SHAPE boundary
1736
+ if (prev.dominant_shape !== null &&
1737
+ cur.dominant_shape !== null &&
1738
+ prev.dominant_shape !== cur.dominant_shape) {
1739
+ const note = refineBoundary({
1740
+ rows,
1741
+ col,
1742
+ bracketStartRow: prev.row_start,
1743
+ bracketEndRow: cur.row_end,
1744
+ prevSig: prev.dominant_shape,
1745
+ newSig: cur.dominant_shape,
1746
+ boundaryKind: "value_shape",
1747
+ signatureOf: valueSigOf,
1748
+ });
1749
+ if (note)
1750
+ intraTileNotes.push(note);
1751
+ }
1752
+ // DISPLAY-format boundary (domain-agnostic format-identity; a downstream LLM names the change)
1753
+ if (captureFormats &&
1754
+ prev.dominant_format !== null &&
1755
+ cur.dominant_format !== null &&
1756
+ prev.dominant_format !== cur.dominant_format) {
1757
+ const note = refineBoundary({
1758
+ rows,
1759
+ col,
1760
+ bracketStartRow: prev.row_start,
1761
+ bracketEndRow: cur.row_end,
1762
+ prevSig: prev.dominant_format,
1763
+ newSig: cur.dominant_format,
1764
+ boundaryKind: "display_format",
1765
+ signatureOf: formatSigOf,
1766
+ });
1767
+ if (note)
1768
+ intraTileNotes.push(note);
1769
+ }
1770
+ }
1771
+ columns.push({ column_index: col, segments, segments_capped: segmentsCapped, intra_tile_notes: intraTileNotes });
1772
+ }
1773
+ return {
1774
+ sheet: sheetName,
1775
+ window,
1776
+ columns,
1777
+ retained_segments: retainedSegments,
1778
+ summed_segment_distinct_count: summedSegmentDistinct,
1779
+ };
1780
+ }
1781
+ // ══════════════════════════════════ END CUT-2 EXPERIMENTAL ══════════════════════════════════
1782
+ /** Build the inventory from xlsx/xlsm bytes (design §11.2). Deterministic and
1783
+ * read-only. `content_sha256` is the RAW-byte hash, supplied by the caller. */
1784
+ export function buildXlsxInventory(args) {
1785
+ const caps = args.caps ?? DEFAULT_DATA_LAYER_CAPS;
1786
+ const valueTileOpts = args.valueTileOpts ?? DEFAULT_VALUE_TILE_OPTS;
1787
+ const valueTiles = [];
1788
+ const risk_signals = [];
1789
+ let captureTruncated = false;
1790
+ // Macro presence is evidence-based: the actual xl/vbaProject.bin part, detected
1791
+ // during pass 1 (onMacro). The .xlsm extension alone is NOT proof — a
1792
+ // macro-enabled-format workbook can be macro-free (avoids false positives).
1793
+ let macroPresent = false;
1794
+ const unsupported = (reason) => unsupportedInventory({
1795
+ sourceRef: args.sourceRef,
1796
+ contentSha256: args.contentSha256,
1797
+ workbookKind: args.workbookKind,
1798
+ reason,
1799
+ });
1800
+ // Pass 1: stream the small parts (workbook, rels, sharedStrings, sheet rels,
1801
+ // tables, pivot definitions) into memory. Worksheets and the giant pivot cache
1802
+ // RECORDS are NOT inflated here (pivotCacheDefinition is; records are skipped).
1803
+ let parts;
1804
+ try {
1805
+ parts = streamCollectEntries({
1806
+ bytes: args.bytes,
1807
+ want: (name) => name === "xl/workbook.xml" ||
1808
+ name === "xl/_rels/workbook.xml.rels" ||
1809
+ name === "xl/sharedStrings.xml" ||
1810
+ name === "xl/styles.xml" ||
1811
+ (name.startsWith("xl/worksheets/_rels/") && name.endsWith(".rels")) ||
1812
+ (name.startsWith("xl/tables/") && name.endsWith(".xml")) ||
1813
+ (name.startsWith("xl/externalLinks/_rels/") && name.endsWith(".rels")) ||
1814
+ (name.startsWith("xl/pivotTables/") && (name.endsWith(".xml") || name.endsWith(".rels"))) ||
1815
+ (name.startsWith("xl/pivotCache/") && name.endsWith(".xml") && name.includes("Definition")),
1816
+ onMacro: () => {
1817
+ macroPresent = true;
1818
+ },
1819
+ onOversized: (name) => {
1820
+ captureTruncated = true;
1821
+ risk_signals.push({
1822
+ kind: "oversized_zip_entry",
1823
+ location: name,
1824
+ literal: `decompressed part exceeds ${XLSX_PART_BYTE_BUDGET} bytes; dropped`,
1825
+ });
1826
+ },
1827
+ });
1828
+ }
1829
+ catch (error) {
1830
+ return unsupported(`xlsx unzip failed: ${error instanceof Error ? error.message : String(error)}`);
1831
+ }
1832
+ const workbookXml = parts.get("xl/workbook.xml");
1833
+ if (!workbookXml) {
1834
+ return unsupported("xlsx missing xl/workbook.xml (not a valid OOXML workbook)");
1835
+ }
1836
+ let workbook;
1837
+ let workbookRels;
1838
+ let sharedStrings;
1839
+ let dateXfIndexes;
1840
+ // Domain-agnostic display-format identity per cellXf index (value-tile boundary detection).
1841
+ let xfFormatIdentity;
1842
+ try {
1843
+ workbook = parseWorkbook(workbookXml);
1844
+ const relsXml = parts.get("xl/_rels/workbook.xml.rels");
1845
+ workbookRels = relsXml ? parseRels(relsXml) : [];
1846
+ const sstXml = parts.get("xl/sharedStrings.xml");
1847
+ sharedStrings = sstXml ? parseSharedStrings(sstXml) : [];
1848
+ const stylesXml = parts.get("xl/styles.xml");
1849
+ const styles = stylesXml
1850
+ ? parseStyles(stylesXml)
1851
+ : { dateXfIndexes: new Set(), xfFormatIdentity: new Map() };
1852
+ dateXfIndexes = styles.dateXfIndexes;
1853
+ xfFormatIdentity = styles.xfFormatIdentity;
1854
+ }
1855
+ catch (error) {
1856
+ return unsupported(`xlsx workbook parse failed: ${error instanceof Error ? error.message : String(error)}`);
1857
+ }
1858
+ // B4: bound the number of OBSERVED worksheets. A pathological many-sheet workbook would
1859
+ // otherwise hold every sheet's row-grid in memory at once and persist an unbounded
1860
+ // inventory (the per-workbook analog of bounded observation). The cap is conservatively
1861
+ // high, so normal workbooks are untouched; when hit, capture_truncated discloses it. This
1862
+ // bounds the set feeding BOTH the sheetByPath population and the final inventory loop.
1863
+ let sheetCountTotal;
1864
+ if (workbook.sheets.length > caps.max_sheets_observed) {
1865
+ sheetCountTotal = workbook.sheets.length; // preserve the true total before slicing
1866
+ workbook.sheets = workbook.sheets.slice(0, caps.max_sheets_observed);
1867
+ captureTruncated = true;
1868
+ }
1869
+ const relById = new Map(workbookRels.map((r) => [r.id, r]));
1870
+ // External workbook links: the workbook rel points to an INTERNAL part
1871
+ // (externalLinks/externalLinkN.xml); the real external file/URL lives in that
1872
+ // part's own .rels (TargetMode=External). Resolve to the actual dependency.
1873
+ const external_links = workbookRels
1874
+ .filter((r) => r.type.toLowerCase().endsWith("externallink"))
1875
+ .map((r) => {
1876
+ const partPath = resolveZipPath("xl", r.target);
1877
+ const relsXml = parts.get(`${path.posix.dirname(partPath)}/_rels/${path.posix.basename(partPath)}.rels`);
1878
+ const ext = relsXml
1879
+ ? parseRels(relsXml).find((rr) => rr.targetMode === "External" || rr.type.toLowerCase().endsWith("externallinkpath"))
1880
+ : undefined;
1881
+ return { target: ext?.target ?? r.target, kind: "external_workbook_link" };
1882
+ });
1883
+ const named_ranges = workbook.definedNames.map((dn) => ({
1884
+ name: dn.name,
1885
+ scope: dn.localSheetId !== undefined ? `sheet:${dn.localSheetId}` : "workbook",
1886
+ refers_to: dn.refersTo,
1887
+ }));
1888
+ // Resolve each sheet's worksheet entry path and its owned tables + pivot tables
1889
+ // (from pass-1 sheet rels + parts — all small, already in memory).
1890
+ const sheetByPath = new Map();
1891
+ const tables = [];
1892
+ const pivotRefs = [];
1893
+ for (const sheetEntry of workbook.sheets) {
1894
+ const rel = relById.get(sheetEntry.rid);
1895
+ const sheetPath = rel ? resolveZipPath("xl", rel.target) : null;
1896
+ if (!sheetPath)
1897
+ continue;
1898
+ sheetByPath.set(sheetPath, sheetEntry);
1899
+ const sheetDir = path.posix.dirname(sheetPath);
1900
+ const sheetRelsXml = parts.get(`${sheetDir}/_rels/${path.posix.basename(sheetPath)}.rels`);
1901
+ if (!sheetRelsXml)
1902
+ continue;
1903
+ for (const r of parseRels(sheetRelsXml)) {
1904
+ const type = r.type.toLowerCase();
1905
+ // pivotTable type also ends with "table" — check it first.
1906
+ if (type.endsWith("pivottable")) {
1907
+ pivotRefs.push({ sheet: sheetEntry.name, baseDir: sheetDir, pivotPath: resolveZipPath(sheetDir, r.target) });
1908
+ }
1909
+ else if (type.endsWith("table")) {
1910
+ const tableXml = parts.get(resolveZipPath(sheetDir, r.target));
1911
+ const table = tableXml ? parseTable(tableXml) : null;
1912
+ if (table)
1913
+ tables.push({ name: table.name, sheet: sheetEntry.name, range: table.ref });
1914
+ }
1915
+ }
1916
+ }
1917
+ // Build pivot tables: parse each pivotTableN.xml + resolve its cache definition
1918
+ // (field names + source) via the pivot's rels. Cache RECORDS are never read.
1919
+ const pivot_tables = [];
1920
+ const pivotHostSheets = new Set();
1921
+ const PIVOT_CAP = 300;
1922
+ for (const { sheet, pivotPath } of pivotRefs) {
1923
+ if (pivot_tables.length >= PIVOT_CAP) {
1924
+ captureTruncated = true;
1925
+ risk_signals.push({
1926
+ kind: "pivot_table_cap",
1927
+ location: path.basename(args.sourceRef),
1928
+ literal: `more than ${PIVOT_CAP} pivot tables; remainder omitted`,
1929
+ });
1930
+ break;
1931
+ }
1932
+ const pivotXml = parts.get(pivotPath);
1933
+ if (!pivotXml)
1934
+ continue;
1935
+ const pt = parsePivotTable(pivotXml);
1936
+ if (!pt)
1937
+ continue;
1938
+ let fieldNames = [];
1939
+ let sourceSheet = null;
1940
+ let sourceRef = null;
1941
+ const pivotDir = path.posix.dirname(pivotPath);
1942
+ const pivotRelsXml = parts.get(`${pivotDir}/_rels/${path.posix.basename(pivotPath)}.rels`);
1943
+ if (pivotRelsXml) {
1944
+ for (const r of parseRels(pivotRelsXml)) {
1945
+ if (!r.type.toLowerCase().endsWith("pivotcachedefinition"))
1946
+ continue;
1947
+ const cacheXml = parts.get(resolveZipPath(pivotDir, r.target));
1948
+ if (cacheXml) {
1949
+ const cache = parsePivotCacheDefinition(cacheXml);
1950
+ fieldNames = cache.fieldNames;
1951
+ sourceSheet = cache.sourceSheet;
1952
+ sourceRef = cache.sourceRef;
1953
+ }
1954
+ break;
1955
+ }
1956
+ }
1957
+ const nameOf = (idx) => fieldNames[idx] ?? `field_${idx}`;
1958
+ pivot_tables.push({
1959
+ name: pt.name,
1960
+ sheet,
1961
+ location: pt.location,
1962
+ source_sheet: sourceSheet,
1963
+ source_ref: sourceRef,
1964
+ row_fields: pt.rowFieldIdx.map(nameOf),
1965
+ column_fields: pt.colFieldIdx.map(nameOf),
1966
+ page_fields: pt.pageFieldIdx.map(nameOf),
1967
+ data_fields: pt.dataFields.map((d) => d.name || nameOf(d.fld)),
1968
+ });
1969
+ pivotHostSheets.add(sheet);
1970
+ }
1971
+ // Pass 2: stream each worksheet through a SAX parser chunk-by-chunk — a
1972
+ // hundred-MB sheet is bounded by the row cap, never held whole.
1973
+ const parsedByPath = new Map();
1974
+ try {
1975
+ streamWorksheets({
1976
+ bytes: args.bytes,
1977
+ want: (name) => sheetByPath.has(name),
1978
+ sinkFor: (name) => {
1979
+ const sheetEntry = sheetByPath.get(name);
1980
+ if (!sheetEntry)
1981
+ return null;
1982
+ const wp = createWorksheetParser({
1983
+ sharedStrings,
1984
+ sheetName: sheetEntry.name,
1985
+ caps,
1986
+ dateXfIndexes,
1987
+ xfFormatIdentity,
1988
+ });
1989
+ return {
1990
+ write: wp.write,
1991
+ finalize: () => {
1992
+ wp.finalize();
1993
+ parsedByPath.set(name, wp.getResult());
1994
+ },
1995
+ };
1996
+ },
1997
+ });
1998
+ }
1999
+ catch {
2000
+ // A corrupt worksheet aborts the streaming push; sheets parsed before it are
2001
+ // retained, and any sheet missing from parsedByPath degrades to unreadable below.
2002
+ }
2003
+ const sheets = [];
2004
+ const per_sheet_data = [];
2005
+ const distinct_value_vocab = [];
2006
+ const crossSheetInput = [];
2007
+ // Workbook-level formula patterns deduped by pattern text ACROSS sheets (the per-sheet
2008
+ // map is keyed within a sheet; the same fill-down on two sheets shares one entry here).
2009
+ const formulaPatternsByText = new Map();
2010
+ let formulaCellsTotal = 0;
2011
+ let formulaCellsTotalIsLowerBound = false;
2012
+ const merged_ranges = [];
2013
+ const data_validations = [];
2014
+ const error_cells = [];
2015
+ for (const sheetEntry of workbook.sheets) {
2016
+ const rel = relById.get(sheetEntry.rid);
2017
+ const sheetPath = rel ? resolveZipPath("xl", rel.target) : null;
2018
+ const parsed = sheetPath ? parsedByPath.get(sheetPath) : undefined;
2019
+ if (!parsed) {
2020
+ // Sheet part missing/unreadable — record it literally, keep the others.
2021
+ risk_signals.push({
2022
+ kind: "unreadable_sheet_part",
2023
+ location: sheetEntry.name,
2024
+ literal: sheetPath ? `missing or unreadable entry ${sheetPath}` : `unresolved relationship ${sheetEntry.rid}`,
2025
+ });
2026
+ sheets.push({
2027
+ name: sheetEntry.name,
2028
+ used_range: null,
2029
+ dimensions: { rows: 0, cols: 0 },
2030
+ hidden: sheetEntry.hidden,
2031
+ protected: false,
2032
+ });
2033
+ per_sheet_data.push({ sheet: sheetEntry.name, layout_kind: "unknown", header_rows: null, header_confidence: "low", columns: [] });
2034
+ continue;
2035
+ }
2036
+ if (parsed.rows_truncated || parsed.caps_hit)
2037
+ captureTruncated = true;
2038
+ // Merge per-sheet formula patterns into the workbook-level set, deduped by pattern
2039
+ // text across sheets: sum occurrences, union sheets/cross_sheet_refs, merge
2040
+ // applied_ranges bounded to the cap, keep the first sample_cell.
2041
+ for (const p of parsed.formula_patterns) {
2042
+ const existing = formulaPatternsByText.get(p.pattern);
2043
+ if (existing) {
2044
+ existing.occurrence_count += p.occurrence_count;
2045
+ if (!existing.sheets.includes(sheetEntry.name))
2046
+ existing.sheets.push(sheetEntry.name);
2047
+ for (const ref of p.cross_sheet_refs) {
2048
+ if (!existing.cross_sheet_refs.includes(ref))
2049
+ existing.cross_sheet_refs.push(ref);
2050
+ }
2051
+ for (const range of p.applied_ranges) {
2052
+ if (existing.applied_ranges.length >= XLSX_FORMULA_APPLIED_RANGE_CAP)
2053
+ break;
2054
+ existing.applied_ranges.push(range);
2055
+ }
2056
+ }
2057
+ else {
2058
+ formulaPatternsByText.set(p.pattern, {
2059
+ pattern: p.pattern,
2060
+ sample_cell: p.sample_cell,
2061
+ occurrence_count: p.occurrence_count,
2062
+ applied_ranges: p.applied_ranges.slice(0, XLSX_FORMULA_APPLIED_RANGE_CAP),
2063
+ sheets: [sheetEntry.name],
2064
+ cross_sheet_refs: [...p.cross_sheet_refs],
2065
+ });
2066
+ }
2067
+ }
2068
+ formulaCellsTotal += parsed.formula_cells_total;
2069
+ if (parsed.formula_patterns_capped)
2070
+ formulaCellsTotalIsLowerBound = true;
2071
+ merged_ranges.push(...parsed.merged_ranges);
2072
+ data_validations.push(...parsed.data_validations);
2073
+ error_cells.push(...parsed.error_cells);
2074
+ const profile = profileSheetRows({ sheetName: sheetEntry.name, rows: parsed.rows, caps });
2075
+ // P1-C2-B′ #3 (gate follow-up): deterministic uniform-formula detection. A column is structurally
2076
+ // complete when EXACTLY ONE single-column formula pattern covers its data cells (occurrence_count
2077
+ // === non_empty_count) — a fill-down whose refs shift but whose structure is one repeated formula
2078
+ // (Excel stores these as shared formulas, already deduped to one pattern with an exact count). The
2079
+ // leaf-read trigger skips such columns. Conservative: a partial-formula column, a multi-formula
2080
+ // column (a row-range break is real structure), or a multi-column fill fails the match and is READ
2081
+ // (safe over-read; never a wrong skip). Same origin-normalized column frame as profile.columns.
2082
+ if (parsed.formula_patterns.length > 0) {
2083
+ const singleColFormula = new Map();
2084
+ for (const p of parsed.formula_patterns) {
2085
+ if (p.applies_to_single_column === null)
2086
+ continue;
2087
+ const agg = singleColFormula.get(p.applies_to_single_column) ?? { total: 0, patterns: 0 };
2088
+ agg.total += p.occurrence_count;
2089
+ agg.patterns += 1;
2090
+ singleColFormula.set(p.applies_to_single_column, agg);
2091
+ }
2092
+ for (const col of profile.columns) {
2093
+ const agg = singleColFormula.get(col.index);
2094
+ if (agg && agg.patterns === 1 && col.non_empty_count > 0 && agg.total === col.non_empty_count) {
2095
+ col.is_uniform_formula = true;
2096
+ }
2097
+ }
2098
+ }
2099
+ // P1-C1: the value-tile projection is a PURE FUNCTION of `parsed.rows` (the exact grid
2100
+ // `profileSheetRows` just consumed) + `parsed.formatRows` (per-cell cellXf, same pass) — zero
2101
+ // source re-scan, computed at the same site. Always produced now (bounded by caps + opts).
2102
+ valueTiles.push(projectSegmentedValueTiles({
2103
+ sheetName: sheetEntry.name,
2104
+ rows: parsed.rows,
2105
+ caps,
2106
+ opts: valueTileOpts,
2107
+ ...(parsed.formatRows ? { formatRows: parsed.formatRows, xfFormatIdentity } : {}),
2108
+ }));
2109
+ if (profile.data_layer_truncated || profile.col_count > caps.max_columns_profiled) {
2110
+ captureTruncated = true;
2111
+ }
2112
+ risk_signals.push(...profile.risk_signals);
2113
+ distinct_value_vocab.push(...profile.distinct_value_vocab);
2114
+ if (profile.column_value_sets.size > 0) {
2115
+ crossSheetInput.push({ sheet: sheetEntry.name, valueSets: profile.column_value_sets });
2116
+ }
2117
+ const dims = parsed.dimensions;
2118
+ sheets.push({
2119
+ name: sheetEntry.name,
2120
+ // Prefer the declared dimension's used range (preserves an offset origin);
2121
+ // fall back to an A1-anchored range derived from scanned extents.
2122
+ used_range: parsed.used_range ?? (dims.rows > 0 ? `R1C1:R${dims.rows}C${dims.cols}` : null),
2123
+ dimensions: dims,
2124
+ hidden: sheetEntry.hidden,
2125
+ protected: parsed.protected,
2126
+ });
2127
+ // A pivot-hosting sheet whose own layout isn't a confident flat table is a
2128
+ // crosstab (uses the otherwise-unset pivot_or_crosstab kind). A sheet with a
2129
+ // real header that merely also anchors a pivot keeps its tabular layout — the
2130
+ // pivots are recorded separately in pivot_tables.
2131
+ const hostsPivot = pivotHostSheets.has(sheetEntry.name);
2132
+ per_sheet_data.push({
2133
+ sheet: sheetEntry.name,
2134
+ layout_kind: hostsPivot && profile.layout_kind !== "tabular" ? "pivot_or_crosstab" : profile.layout_kind,
2135
+ header_rows: profile.header_rows,
2136
+ header_confidence: profile.header_confidence,
2137
+ columns: profile.columns,
2138
+ });
2139
+ }
2140
+ const { overlaps: cross_sheet_key_overlap, truncated: overlapTruncated } = computeCrossSheetKeyOverlap(crossSheetInput, caps);
2141
+ if (overlapTruncated)
2142
+ captureTruncated = true;
2143
+ if (macroPresent) {
2144
+ risk_signals.push({ kind: "macro_present", location: path.basename(args.sourceRef), literal: "workbook carries a VBA project" });
2145
+ }
2146
+ if (external_links.length > 0) {
2147
+ risk_signals.push({ kind: "external_links_present", location: path.basename(args.sourceRef), literal: `${external_links.length} external workbook link(s)` });
2148
+ }
2149
+ return {
2150
+ adapter_id: SPREADSHEET_OBSERVER_ADAPTER_ID,
2151
+ adapter_version: SPREADSHEET_OBSERVER_ADAPTER_VERSION,
2152
+ source_ref: path.resolve(args.sourceRef),
2153
+ content_sha256: args.contentSha256,
2154
+ workbook_kind: args.workbookKind,
2155
+ inspection_method: "structure_inspected_only",
2156
+ sheets,
2157
+ named_ranges,
2158
+ tables,
2159
+ pivot_tables,
2160
+ formula_patterns: [...formulaPatternsByText.values()],
2161
+ formula_cells_total: formulaCellsTotal,
2162
+ formula_cells_total_is_lower_bound: formulaCellsTotalIsLowerBound,
2163
+ merged_ranges,
2164
+ data_validations,
2165
+ external_links,
2166
+ error_cells,
2167
+ macro_present: macroPresent,
2168
+ risk_signals,
2169
+ per_sheet_data,
2170
+ distinct_value_vocab,
2171
+ cross_sheet_key_overlap,
2172
+ data_layer_caps: caps,
2173
+ capture_truncated: captureTruncated,
2174
+ ...(sheetCountTotal !== undefined ? { sheet_count_total: sheetCountTotal } : {}),
2175
+ segmented_value_tiles: valueTiles,
2176
+ value_tile_config: valueTileOpts,
2177
+ unsupported_reason: null,
2178
+ };
2179
+ }
2180
+ const SPREADSHEET_EXTENSION_KINDS = {
2181
+ ".csv": "csv",
2182
+ ".tsv": "tsv",
2183
+ ".xlsx": "xlsx",
2184
+ ".xlsm": "xlsm",
2185
+ ".xls": "xls",
2186
+ ".xlsb": "xlsb",
2187
+ ".ods": "ods",
2188
+ };
2189
+ function unsupportedInventory(args) {
2190
+ return {
2191
+ adapter_id: SPREADSHEET_OBSERVER_ADAPTER_ID,
2192
+ adapter_version: SPREADSHEET_OBSERVER_ADAPTER_VERSION,
2193
+ source_ref: path.resolve(args.sourceRef),
2194
+ content_sha256: args.contentSha256,
2195
+ workbook_kind: args.workbookKind,
2196
+ inspection_method: "structure_inspected_only",
2197
+ sheets: [],
2198
+ named_ranges: [],
2199
+ tables: [],
2200
+ pivot_tables: [],
2201
+ formula_patterns: [],
2202
+ formula_cells_total: 0,
2203
+ formula_cells_total_is_lower_bound: false,
2204
+ merged_ranges: [],
2205
+ data_validations: [],
2206
+ external_links: [],
2207
+ error_cells: [],
2208
+ macro_present: false,
2209
+ risk_signals: [],
2210
+ per_sheet_data: [],
2211
+ distinct_value_vocab: [],
2212
+ cross_sheet_key_overlap: [],
2213
+ data_layer_caps: DEFAULT_DATA_LAYER_CAPS,
2214
+ capture_truncated: false,
2215
+ unsupported_reason: args.reason,
2216
+ };
2217
+ }
2218
+ /** A pre-read gate on the compressed source size: the whole file is loaded into
2219
+ * a Buffer (then streaming-unzipped), so an oversized source must fail loud with
2220
+ * an unsupported_reason rather than OOM the host before caps can apply. */
2221
+ const MAX_SOURCE_BYTES = 1024 * 1024 * 1024; // 1 GiB
2222
+ /** Observe a spreadsheet source file. csv/tsv use the pure-Node extractor and
2223
+ * xlsx/xlsm the streaming fflate+saxes extractor; xls/xlsb/ods return
2224
+ * `unsupported_reason`. `content_sha256` is always the RAW-byte hash. */
2225
+ export async function observeSpreadsheetSource(sourceRef, opts) {
2226
+ const ext = path.extname(sourceRef).toLowerCase();
2227
+ const workbookKind = SPREADSHEET_EXTENSION_KINDS[ext];
2228
+ let bytes;
2229
+ try {
2230
+ const stat = await fs.stat(sourceRef);
2231
+ if (stat.size > MAX_SOURCE_BYTES) {
2232
+ return unsupportedInventory({
2233
+ sourceRef,
2234
+ contentSha256: "",
2235
+ workbookKind: workbookKind ?? "csv",
2236
+ reason: `source too large (${stat.size} bytes > ${MAX_SOURCE_BYTES}); not read`,
2237
+ });
2238
+ }
2239
+ bytes = await fs.readFile(sourceRef);
2240
+ }
2241
+ catch (error) {
2242
+ const code = error.code;
2243
+ return unsupportedInventory({
2244
+ sourceRef,
2245
+ contentSha256: "",
2246
+ workbookKind: workbookKind ?? "csv",
2247
+ reason: `source unreadable (${code ?? "unknown"})`,
2248
+ });
2249
+ }
2250
+ const contentSha256 = sha256Hex(bytes);
2251
+ if (workbookKind === undefined) {
2252
+ return unsupportedInventory({ sourceRef, contentSha256, workbookKind: "csv", reason: `unrecognized spreadsheet extension: ${ext || "(none)"}` });
2253
+ }
2254
+ // CRASH ISOLATION: structural extraction can throw — most concretely a 512MB–1GiB CSV
2255
+ // whose bytes pass the size gate but exceed V8's ~512MB string limit on
2256
+ // `bytes.toString("utf8")`, plus any unexpected parser failure. Degrade THIS workbook to
2257
+ // an honest unsupported inventory (preserving the already-computed raw-byte hash) instead
2258
+ // of throwing out of the observer and aborting the whole review prep / reconstruct run.
2259
+ try {
2260
+ if (workbookKind === "csv" || workbookKind === "tsv") {
2261
+ return buildCsvInventory({
2262
+ sourceRef,
2263
+ content: bytes.toString("utf8"),
2264
+ contentSha256,
2265
+ // The .tsv extension is definitive — force tabs instead of re-detecting.
2266
+ ...(workbookKind === "tsv" ? { delimiter: "\t" } : {}),
2267
+ ...(opts?.caps ? { caps: opts.caps } : {}),
2268
+ });
2269
+ }
2270
+ if (workbookKind === "xlsx" || workbookKind === "xlsm") {
2271
+ return buildXlsxInventory({
2272
+ sourceRef,
2273
+ bytes,
2274
+ contentSha256,
2275
+ workbookKind,
2276
+ ...(opts?.caps ? { caps: opts.caps } : {}),
2277
+ });
2278
+ }
2279
+ // xls (BIFF binary) / xlsb (binary OOXML) / ods (a different ZIP/XML schema) —
2280
+ // separate parsers, not yet implemented (design §11.2 scoped xlsx/xlsm first).
2281
+ // The workbook_kind is preserved (not mislabeled csv) so the artifact is honest.
2282
+ return unsupportedInventory({
2283
+ sourceRef,
2284
+ contentSha256,
2285
+ workbookKind,
2286
+ reason: `${workbookKind} extraction not yet implemented (xls/xlsb/ods deferred; xlsx/xlsm supported)`,
2287
+ });
2288
+ }
2289
+ catch (error) {
2290
+ return unsupportedInventory({
2291
+ sourceRef,
2292
+ contentSha256,
2293
+ workbookKind,
2294
+ reason: `structural observation failed (${error instanceof Error ? error.message : String(error)})`,
2295
+ });
2296
+ }
2297
+ }
2298
+ export const DEFAULT_CELL_READ_CAPS = {
2299
+ maxRegions: 8,
2300
+ maxCellsPerRegion: 200,
2301
+ cellCharCap: 200,
2302
+ totalReadCharCap: 20_000,
2303
+ };
2304
+ /** Re-stream only the named worksheets and return their origin-normalized row grids (the same
2305
+ * `parsed.rows` the inventory pass produced). Mirrors buildXlsxInventory's pass-1 part collection +
2306
+ * pass-2 worksheet streaming, scoped to the requested sheets and WITHOUT the aggregate profiling.
2307
+ * May throw on a corrupt archive — the caller (readTargetedCellValues) wraps this in crash isolation. */
2308
+ function streamWorksheetGridsByName(bytes, caps, wantedSheetNames) {
2309
+ const out = new Map();
2310
+ const parts = streamCollectEntries({
2311
+ bytes,
2312
+ want: (name) => name === "xl/workbook.xml" ||
2313
+ name === "xl/_rels/workbook.xml.rels" ||
2314
+ name === "xl/sharedStrings.xml" ||
2315
+ name === "xl/styles.xml",
2316
+ onMacro: () => { },
2317
+ onOversized: () => { },
2318
+ });
2319
+ const workbookXml = parts.get("xl/workbook.xml");
2320
+ if (!workbookXml)
2321
+ return out;
2322
+ const workbook = parseWorkbook(workbookXml);
2323
+ const relsXml = parts.get("xl/_rels/workbook.xml.rels");
2324
+ const workbookRels = relsXml ? parseRels(relsXml) : [];
2325
+ const sstXml = parts.get("xl/sharedStrings.xml");
2326
+ const sharedStrings = sstXml ? parseSharedStrings(sstXml) : [];
2327
+ const stylesXml = parts.get("xl/styles.xml");
2328
+ const dateXfIndexes = stylesXml ? parseStyles(stylesXml).dateXfIndexes : new Set();
2329
+ const relById = new Map(workbookRels.map((r) => [r.id, r]));
2330
+ // sheet name → worksheet part path, restricted to the wanted sheets (so we stream only those).
2331
+ const nameByPath = new Map();
2332
+ for (const sheetEntry of workbook.sheets) {
2333
+ if (!wantedSheetNames.has(sheetEntry.name))
2334
+ continue;
2335
+ const rel = relById.get(sheetEntry.rid);
2336
+ const sheetPath = rel ? resolveZipPath("xl", rel.target) : null;
2337
+ if (sheetPath)
2338
+ nameByPath.set(sheetPath, sheetEntry.name);
2339
+ }
2340
+ if (nameByPath.size === 0)
2341
+ return out;
2342
+ streamWorksheets({
2343
+ bytes,
2344
+ want: (name) => nameByPath.has(name),
2345
+ sinkFor: (name) => {
2346
+ const sheetName = nameByPath.get(name);
2347
+ if (!sheetName)
2348
+ return null;
2349
+ // No xfFormatIdentity → no per-cell format capture; we only need the value grid.
2350
+ const wp = createWorksheetParser({ sharedStrings, sheetName, caps, dateXfIndexes });
2351
+ return {
2352
+ write: wp.write,
2353
+ finalize: () => {
2354
+ out.set(sheetName, wp.getResult().rows);
2355
+ },
2356
+ };
2357
+ },
2358
+ });
2359
+ return out;
2360
+ }
2361
+ /**
2362
+ * Read a bounded slice of RAW cell values from an authorized source, for the maturation value-read
2363
+ * limitation-discharge judgment (design §15.4). The caller (runtime, not the LLM) supplies the resolved
2364
+ * absolute `sourceRef` and grid-frame `selections`. Returns the materialized cells + honest read
2365
+ * provenance (cells_read, truncated, content_sha256). NEVER throws — a corrupt/oversized/unreadable
2366
+ * source degrades to `{ regions: [], unreadable_reason }` (mirrors observeSpreadsheetSource's crash
2367
+ * isolation), so the value-read stage degrades gracefully to a blocked-preserving zero-discharge.
2368
+ */
2369
+ export async function readTargetedCellValues(args) {
2370
+ const caps = args.caps ?? DEFAULT_CELL_READ_CAPS;
2371
+ const dataCaps = args.dataLayerCaps ?? DEFAULT_DATA_LAYER_CAPS;
2372
+ const empty = (reason, sha = null) => ({
2373
+ regions: [],
2374
+ total_cells_read: 0,
2375
+ truncated: false,
2376
+ content_sha256: sha,
2377
+ unreadable_reason: reason,
2378
+ });
2379
+ const ext = path.extname(args.sourceRef).toLowerCase();
2380
+ const workbookKind = SPREADSHEET_EXTENSION_KINDS[ext];
2381
+ let bytes;
2382
+ try {
2383
+ const stat = await fs.stat(args.sourceRef);
2384
+ if (stat.size > MAX_SOURCE_BYTES)
2385
+ return empty(`source too large (${stat.size} bytes); not read`);
2386
+ bytes = await fs.readFile(args.sourceRef);
2387
+ }
2388
+ catch (error) {
2389
+ return empty(`source unreadable (${error.code ?? "unknown"})`);
2390
+ }
2391
+ const contentSha256 = sha256Hex(bytes);
2392
+ if (workbookKind === undefined) {
2393
+ return empty(`unrecognized spreadsheet extension: ${ext || "(none)"}`, contentSha256);
2394
+ }
2395
+ // CRASH ISOLATION (design §15.4): the streaming parsers can throw on a corrupt/oversized workbook or
2396
+ // a >512MB CSV string. Degrade to an honest empty read (preserving the byte hash) — never throw out
2397
+ // of the maturation value-read stage.
2398
+ let rowsBySheet;
2399
+ try {
2400
+ if (workbookKind === "csv" || workbookKind === "tsv") {
2401
+ const text = bytes.toString("utf8");
2402
+ const stripped = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
2403
+ const delimiter = workbookKind === "tsv" ? "\t" : detectDelimiter(stripped);
2404
+ const { rows } = parseCsv(stripped, delimiter, dataCaps.max_rows_scanned_per_sheet + 1);
2405
+ // A CSV is a single logical sheet keyed by the file basename (buildCsvInventory convention).
2406
+ rowsBySheet = new Map([[path.basename(args.sourceRef), rows]]);
2407
+ }
2408
+ else if (workbookKind === "xlsx" || workbookKind === "xlsm") {
2409
+ rowsBySheet = streamWorksheetGridsByName(bytes, dataCaps, new Set(args.selections.map((s) => s.sheet)));
2410
+ }
2411
+ else {
2412
+ return empty(`${workbookKind} extraction not yet implemented`, contentSha256);
2413
+ }
2414
+ }
2415
+ catch (error) {
2416
+ return empty(`targeted cell-read failed (${error instanceof Error ? error.message : String(error)})`, contentSha256);
2417
+ }
2418
+ const regions = [];
2419
+ let totalCells = 0;
2420
+ let totalChars = 0;
2421
+ let truncated = false;
2422
+ for (const sel of args.selections) {
2423
+ if (regions.length >= caps.maxRegions) {
2424
+ truncated = true;
2425
+ break;
2426
+ }
2427
+ const rows = rowsBySheet.get(sel.sheet);
2428
+ const cells = [];
2429
+ let regionTruncated = false;
2430
+ if (rows) {
2431
+ // grid_row is 1-based (the value-tile segment frame); parsed.rows is 0-based. Null bounds = whole
2432
+ // column. Clamp to the materialized grid so an out-of-range pick reads nothing (never throws).
2433
+ const startIdx = sel.grid_row_start != null ? Math.max(0, sel.grid_row_start - 1) : 0;
2434
+ const endIdx = sel.grid_row_end != null ? Math.min(rows.length - 1, sel.grid_row_end - 1) : rows.length - 1;
2435
+ for (let r = startIdx; r <= endIdx; r += 1) {
2436
+ if (cells.length >= caps.maxCellsPerRegion) {
2437
+ regionTruncated = true;
2438
+ break;
2439
+ }
2440
+ if (totalChars >= caps.totalReadCharCap) {
2441
+ regionTruncated = true;
2442
+ truncated = true;
2443
+ break;
2444
+ }
2445
+ const raw = rows[r]?.[sel.grid_column_index] ?? "";
2446
+ if (raw === "")
2447
+ continue; // skip empty cells (sparse grid) — only real values count
2448
+ // Per-cell clipping is a form of truncation: a satisfied discharge must not rest on a clipped
2449
+ // (incomplete) cell value any more than on a row-capped read (design §16.2, onto issue-004).
2450
+ let value = raw;
2451
+ if (raw.length > caps.cellCharCap) {
2452
+ value = raw.slice(0, caps.cellCharCap);
2453
+ regionTruncated = true;
2454
+ }
2455
+ cells.push({ grid_row: r + 1, grid_column_index: sel.grid_column_index, value });
2456
+ totalCells += 1;
2457
+ totalChars += value.length;
2458
+ }
2459
+ }
2460
+ if (regionTruncated)
2461
+ truncated = true;
2462
+ regions.push({
2463
+ sheet: sel.sheet,
2464
+ grid_column_index: sel.grid_column_index,
2465
+ grid_row_start: sel.grid_row_start ?? null,
2466
+ grid_row_end: sel.grid_row_end ?? null,
2467
+ cells,
2468
+ cells_read: cells.length,
2469
+ truncated: regionTruncated,
2470
+ });
2471
+ }
2472
+ return { regions, total_cells_read: totalCells, truncated, content_sha256: contentSha256 };
2473
+ }
2474
+ /**
2475
+ * The single shared projection of a workbook inventory used by BOTH reconstruct and
2476
+ * review. It returns a structural + aggregate-only view: raw cell values —
2477
+ * `distinct_value_vocab[].top_values` (and any future sample/key-value fields) — are
2478
+ * excluded. Both consumers route through this so the aggregate-only default (the
2479
+ * inventory is a structural/aggregate index, not a data dump) is enforced in ONE
2480
+ * place and cannot drift between them.
2481
+ *
2482
+ * design-C narrowing: `data_validations[].members` IS value-bearing but passes through
2483
+ * unchanged — it is DECLARED schema (the type=list enum labels from formula1), not observed
2484
+ * data, and is already bounded at emission (VALIDATION_MEMBER_COUNT_CAP / _CHAR_CAP, never
2485
+ * sourced from cell values). Per-column cardinality (distinct_count / non_empty_count) is a
2486
+ * COUNT, not a value, so it likewise passes through.
2487
+ */
2488
+ export function projectInventoryForAdmission(inventory) {
2489
+ return {
2490
+ ...inventory,
2491
+ // Reconstruct each vocab entry from aggregate-only fields, dropping the
2492
+ // optional raw-value `top_values` entirely.
2493
+ distinct_value_vocab: inventory.distinct_value_vocab.map((entry) => ({
2494
+ sheet: entry.sheet,
2495
+ column: entry.column,
2496
+ distinct_count: entry.distinct_count,
2497
+ distinct_count_is_estimate: entry.distinct_count_is_estimate,
2498
+ })),
2499
+ };
2500
+ }
2501
+ export const DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS = {
2502
+ max_formula_patterns: 200,
2503
+ max_sheets: 50,
2504
+ max_columns_per_sheet: 64,
2505
+ max_distinct_value_vocab: 200,
2506
+ max_pivot_tables: 50,
2507
+ max_cross_sheet_overlaps: 50,
2508
+ max_pairwise_per_overlap: 16,
2509
+ max_named_ranges: 50,
2510
+ max_tables: 50,
2511
+ max_data_validations: 50,
2512
+ max_external_links: 50,
2513
+ max_error_cells: 50,
2514
+ max_merged_ranges: 50,
2515
+ max_risk_signals: 50,
2516
+ // P1-C1 #5 PRELIMINARY (live calibration = INV-BENCH-1 follow-up).
2517
+ max_value_tile_sheets: 50,
2518
+ max_value_tile_columns_per_sheet: 32,
2519
+ max_value_tile_notes_per_column: 16,
2520
+ };
2521
+ /**
2522
+ * Scale every prompt-projection cap by a single window multiplier (Stage 1
2523
+ * window-proportional review budget). Pure, model-agnostic: the only input is a
2524
+ * numeric multiplier (the caller derives it from the lens model's context
2525
+ * window), so no model literal reaches these caps (INV-CFG-1). Each dim is
2526
+ * `ceil(DEFAULT[dim] * multiplier)`, so the relative projection SHAPE (the cap
2527
+ * ratios) is preserved and only the SIZE grows with the window. multiplier = 1
2528
+ * returns values byte-equal to DEFAULT (ceil(int * 1) === int), which is the
2529
+ * no-regression floor for model-unaware runs.
2530
+ */
2531
+ export function deriveWorkbookInventoryPromptCaps(multiplier) {
2532
+ const scale = (dim) => Math.ceil(dim * multiplier);
2533
+ return {
2534
+ max_formula_patterns: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_formula_patterns),
2535
+ max_sheets: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_sheets),
2536
+ max_columns_per_sheet: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_columns_per_sheet),
2537
+ max_distinct_value_vocab: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_distinct_value_vocab),
2538
+ max_pivot_tables: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_pivot_tables),
2539
+ max_cross_sheet_overlaps: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_cross_sheet_overlaps),
2540
+ max_pairwise_per_overlap: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_pairwise_per_overlap),
2541
+ max_named_ranges: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_named_ranges),
2542
+ max_tables: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_tables),
2543
+ max_data_validations: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_data_validations),
2544
+ max_external_links: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_external_links),
2545
+ max_error_cells: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_error_cells),
2546
+ max_merged_ranges: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_merged_ranges),
2547
+ max_risk_signals: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_risk_signals),
2548
+ max_value_tile_sheets: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_value_tile_sheets),
2549
+ max_value_tile_columns_per_sheet: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_value_tile_columns_per_sheet),
2550
+ max_value_tile_notes_per_column: scale(DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS.max_value_tile_notes_per_column),
2551
+ };
2552
+ }
2553
+ /**
2554
+ * Bounded, representative prompt projection of a workbook inventory (SIZE axis).
2555
+ * Pure, deterministic, total: it never mutates the input and never throws, mirroring
2556
+ * `projectInventoryForAdmission`. The returned `sections` enumerate exactly the
2557
+ * arrays that were trimmed (kept < total) so truncation is surfaced honestly rather
2558
+ * than silently swallowed.
2559
+ */
2560
+ export function projectInventoryForPrompt(inventory, caps = DEFAULT_WORKBOOK_INVENTORY_PROMPT_CAPS, opts = {}) {
2561
+ // P1-C1: value-tile is RECONSTRUCT-ONLY in prompts (§12 T8). value_tile_config is an internal
2562
+ // resume input and is NEVER prompt-projected; segmented_value_tiles is omitted from the projection
2563
+ // unless the caller opts in (#5). The shared review path (renderSpreadsheetStructuralView) does NOT
2564
+ // opt in → its rendered view stays byte-stable and the value tile never leaks into a review prompt.
2565
+ const { segmented_value_tiles: rawValueTiles, value_tile_config, ...inventoryRest } = inventory;
2566
+ void value_tile_config;
2567
+ const sections = [];
2568
+ const record = (section, kept, total) => {
2569
+ if (kept < total)
2570
+ sections.push({ section, kept, total });
2571
+ };
2572
+ // Formula patterns are already deduped (a fill-down is one entry), so a single head-N
2573
+ // over the small distinct set bounds the prompt. formula_cells_total (+ is_lower_bound)
2574
+ // pass through unchanged below — the honest true-total is never trimmed.
2575
+ const formulaPatterns = inventory.formula_patterns.slice(0, caps.max_formula_patterns);
2576
+ record("formula_patterns", formulaPatterns.length, inventory.formula_patterns.length);
2577
+ // per_sheet_data: cap the number of SHEETS first (bounds a high-sheet-count
2578
+ // workbook), then cap each kept sheet's columns[]. columnsTotal sums only kept
2579
+ // sheets — a dropped sheet's column loss is implied by the per_sheet_data record.
2580
+ const keptSheets = inventory.per_sheet_data.slice(0, caps.max_sheets);
2581
+ record("per_sheet_data", keptSheets.length, inventory.per_sheet_data.length);
2582
+ let columnsKept = 0;
2583
+ let columnsTotal = 0;
2584
+ const perSheetData = keptSheets.map((sheet) => {
2585
+ columnsTotal += sheet.columns.length;
2586
+ // design-C: when a sheet has more columns than the fixed cap, RE-SELECT which columns
2587
+ // survive by residual priority (highest cardinality first, the most informative residual
2588
+ // signal) instead of a positional head-N — then emit the survivors in ORIGINAL index
2589
+ // order for readability. Pure: sort a COPY, never mutate the input. The kept COUNT is
2590
+ // unchanged (cap), so the truncation disclosure (kept/total) stays accurate.
2591
+ let columns;
2592
+ if (sheet.columns.length <= caps.max_columns_per_sheet) {
2593
+ columns = sheet.columns;
2594
+ }
2595
+ else {
2596
+ const selected = new Set([...sheet.columns]
2597
+ .sort(compareColumnResidualDesc)
2598
+ .slice(0, caps.max_columns_per_sheet));
2599
+ columns = sheet.columns.filter((col) => selected.has(col));
2600
+ }
2601
+ columnsKept += columns.length;
2602
+ return { ...sheet, columns };
2603
+ });
2604
+ record("per_sheet_data.columns", columnsKept, columnsTotal);
2605
+ const distinctValueVocab = inventory.distinct_value_vocab.slice(0, caps.max_distinct_value_vocab);
2606
+ record("distinct_value_vocab", distinctValueVocab.length, inventory.distinct_value_vocab.length);
2607
+ const pivotTables = inventory.pivot_tables.slice(0, caps.max_pivot_tables);
2608
+ record("pivot_tables", pivotTables.length, inventory.pivot_tables.length);
2609
+ // cross_sheet_key_overlap: cap the entry list AND each kept entry's pairwise list.
2610
+ let pairwiseKept = 0;
2611
+ let pairwiseTotal = 0;
2612
+ const crossSheetOverlaps = inventory.cross_sheet_key_overlap
2613
+ .slice(0, caps.max_cross_sheet_overlaps)
2614
+ .map((overlap) => {
2615
+ pairwiseTotal += overlap.pairwise_overlap.length;
2616
+ const pairwise = overlap.pairwise_overlap.slice(0, caps.max_pairwise_per_overlap);
2617
+ pairwiseKept += pairwise.length;
2618
+ return { ...overlap, pairwise_overlap: pairwise };
2619
+ });
2620
+ record("cross_sheet_key_overlap", crossSheetOverlaps.length, inventory.cross_sheet_key_overlap.length);
2621
+ // pairwiseTotal only sums the KEPT entries; report the trim only when those entries
2622
+ // were themselves trimmed (a dropped entry's pairwise loss is implied by the entry
2623
+ // record above, so double-counting it would overstate the pairwise total).
2624
+ record("cross_sheet_key_overlap.pairwise_overlap", pairwiseKept, pairwiseTotal);
2625
+ const namedRanges = inventory.named_ranges.slice(0, caps.max_named_ranges);
2626
+ record("named_ranges", namedRanges.length, inventory.named_ranges.length);
2627
+ const tables = inventory.tables.slice(0, caps.max_tables);
2628
+ record("tables", tables.length, inventory.tables.length);
2629
+ const dataValidations = inventory.data_validations.slice(0, caps.max_data_validations);
2630
+ record("data_validations", dataValidations.length, inventory.data_validations.length);
2631
+ const externalLinks = inventory.external_links.slice(0, caps.max_external_links);
2632
+ record("external_links", externalLinks.length, inventory.external_links.length);
2633
+ const errorCells = inventory.error_cells.slice(0, caps.max_error_cells);
2634
+ record("error_cells", errorCells.length, inventory.error_cells.length);
2635
+ const mergedRanges = inventory.merged_ranges.slice(0, caps.max_merged_ranges);
2636
+ record("merged_ranges", mergedRanges.length, inventory.merged_ranges.length);
2637
+ const riskSignals = inventory.risk_signals.slice(0, caps.max_risk_signals);
2638
+ record("risk_signals", riskSignals.length, inventory.risk_signals.length);
2639
+ // P1-C1 #5: bounded value-tile projection (reconstruct opt-in). Keep only boundary-bearing columns
2640
+ // (intra_tile_notes are the load-bearing witness — prev/new shape+format + the EXACT transition
2641
+ // rows) and DROP the verbose per-segment aggregates from the prompt (segments:[]). Bounded by
2642
+ // sheets/columns/notes caps; every trim is recorded for honest truncation disclosure.
2643
+ let valueTilesProjected;
2644
+ if (opts.includeValueTiles && rawValueTiles) {
2645
+ const keptVtSheets = rawValueTiles.slice(0, caps.max_value_tile_sheets);
2646
+ record("segmented_value_tiles", keptVtSheets.length, rawValueTiles.length);
2647
+ let vtColsKept = 0;
2648
+ let vtColsTotal = 0;
2649
+ let vtNotesKept = 0;
2650
+ let vtNotesTotal = 0;
2651
+ valueTilesProjected = keptVtSheets.map((s) => {
2652
+ const boundaryCols = s.columns.filter((c) => c.intra_tile_notes.length > 0);
2653
+ vtColsTotal += boundaryCols.length;
2654
+ const keptCols = boundaryCols.slice(0, caps.max_value_tile_columns_per_sheet);
2655
+ vtColsKept += keptCols.length;
2656
+ const columns = keptCols.map((c) => {
2657
+ vtNotesTotal += c.intra_tile_notes.length;
2658
+ const notes = c.intra_tile_notes.slice(0, caps.max_value_tile_notes_per_column);
2659
+ vtNotesKept += notes.length;
2660
+ return { ...c, segments: [], intra_tile_notes: notes };
2661
+ });
2662
+ return { ...s, columns };
2663
+ });
2664
+ record("segmented_value_tiles.columns", vtColsKept, vtColsTotal);
2665
+ record("segmented_value_tiles.intra_tile_notes", vtNotesKept, vtNotesTotal);
2666
+ }
2667
+ return {
2668
+ inventory: {
2669
+ ...inventoryRest,
2670
+ formula_patterns: formulaPatterns,
2671
+ per_sheet_data: perSheetData,
2672
+ distinct_value_vocab: distinctValueVocab,
2673
+ pivot_tables: pivotTables,
2674
+ cross_sheet_key_overlap: crossSheetOverlaps,
2675
+ named_ranges: namedRanges,
2676
+ tables,
2677
+ data_validations: dataValidations,
2678
+ external_links: externalLinks,
2679
+ error_cells: errorCells,
2680
+ merged_ranges: mergedRanges,
2681
+ risk_signals: riskSignals,
2682
+ // P1-C1 §12 T8/#5: value-tile included ONLY when the caller opts in (reconstruct), bounded to
2683
+ // boundary witnesses; omitted by default so review's shared projection stays byte-stable.
2684
+ ...(valueTilesProjected ? { segmented_value_tiles: valueTilesProjected } : {}),
2685
+ },
2686
+ truncated: sections.length > 0,
2687
+ sections,
2688
+ };
2689
+ }