onto-mcp 0.4.11 → 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.
- package/.onto/authority/core-lexicon.yaml +1 -1
- package/.onto/authority/supported-models.yaml +153 -0
- package/.onto/domains/accounting-kr/competency_qs.md +153 -153
- package/.onto/processes/reconstruct/obligation-coverage-ledger.yaml +842 -0
- package/.onto/processes/reconstruct/obligation-coverage-recorded.yaml +225 -0
- package/.onto/processes/reconstruct/ontology-seeding-and-maturation-design.md +93 -54
- package/.onto/processes/reconstruct/reconstruct-contract-registry.yaml +202 -49
- package/.onto/processes/reconstruct/reconstruct-execution-ux-contract.md +72 -1
- package/.onto/processes/reconstruct/source-profiles/spreadsheet.md +35 -0
- package/.onto/processes/review/nesting-batch-worker-contract.md +1 -1
- package/.onto/processes/review/productized-live-path.md +9 -5
- package/.onto/processes/review/prompt-execution-runner-contract.md +32 -8
- package/.onto/processes/review/review-target-profile-contract.md +47 -4
- package/.onto/processes/shared/pipeline-execution-ledger-contract.md +11 -0
- package/.onto/processes/shared/target-material-kind-contract.md +1 -1
- package/README.md +64 -1
- package/dist/cli.js +11 -1
- package/dist/core-api/reconstruct-api.js +679 -18
- package/dist/core-api/review-api.js +70 -5
- package/dist/core-api/review-progress.js +7 -0
- package/dist/core-api/runtime-observation.js +7 -0
- package/dist/core-runtime/artifact-io.js +131 -0
- package/dist/core-runtime/cli/claude-code-review-unit-executor.js +21 -9
- package/dist/core-runtime/cli/claude-nesting-batch-worker.js +4 -2
- package/dist/core-runtime/cli/materialize-review-prompt-packets.js +68 -14
- package/dist/core-runtime/cli/prepare-review-session.js +34 -1
- package/dist/core-runtime/cli/render-review-final-output.js +2 -1
- package/dist/core-runtime/cli/run-review-prompt-execution.js +758 -48
- package/dist/core-runtime/cli/run-seats.js +18 -0
- package/dist/core-runtime/cli/runtime-submit-context.js +10 -2
- package/dist/core-runtime/cli/unit-resubmit.js +269 -0
- package/dist/core-runtime/cli/worker-structured-output.js +80 -2
- package/dist/core-runtime/discovery/host-detection.js +71 -22
- package/dist/core-runtime/discovery/review-cert-assemble.js +208 -0
- package/dist/core-runtime/discovery/review-cert-record.js +442 -0
- package/dist/core-runtime/discovery/review-execution-units.js +22 -0
- package/dist/core-runtime/discovery/seat-inventory.js +167 -0
- package/dist/core-runtime/discovery/settings-chain.js +311 -52
- package/dist/core-runtime/discovery/supported-models.js +289 -25
- package/dist/core-runtime/discovery/synthesize-cert-assemble.js +197 -0
- package/dist/core-runtime/discovery/synthesize-cert-capsule.js +399 -0
- package/dist/core-runtime/discovery/synthesize-cert-judge.js +340 -0
- package/dist/core-runtime/discovery/synthesize-cert-loop.js +234 -0
- package/dist/core-runtime/discovery/synthesize-cert-mutation.js +117 -0
- package/dist/core-runtime/discovery/synthesize-cert-packet.js +182 -0
- package/dist/core-runtime/discovery/synthesize-cert-record.js +831 -0
- package/dist/core-runtime/discovery/synthesize-cert-sampler.js +336 -0
- package/dist/core-runtime/effort-calibration-ingest.js +326 -0
- package/dist/core-runtime/effort-calibration-reconstruct.js +47 -0
- package/dist/core-runtime/effort-calibration-review.js +22 -0
- package/dist/core-runtime/effort-calibration-sweep.js +110 -0
- package/dist/core-runtime/effort-frontier.js +134 -0
- package/dist/core-runtime/llm/claude-bin.js +77 -0
- package/dist/core-runtime/llm/dispatch-breaker.js +412 -0
- package/dist/core-runtime/llm/dispatch-fallback-adapter-capabilities.js +33 -0
- package/dist/core-runtime/llm/llm-caller.js +316 -35
- package/dist/core-runtime/llm/mock-llm-realization.js +35 -4
- package/dist/core-runtime/llm/model-switcher.js +1 -1
- package/dist/core-runtime/llm/openai-responses-incomplete-error.js +46 -0
- package/dist/core-runtime/llm/sealed-dispatch-capability.js +359 -0
- package/dist/core-runtime/llm/structured-dispatch-error.js +15 -0
- package/dist/core-runtime/onboard/claude-profile-scan.js +65 -0
- package/dist/core-runtime/onboard/cli-host.js +5 -2
- package/dist/core-runtime/onboard/configure-provider.js +33 -1
- package/dist/core-runtime/onboard/host-target.js +28 -3
- package/dist/core-runtime/onboard/register.js +54 -12
- package/dist/core-runtime/pipeline-execution-ledger.js +10 -0
- package/dist/core-runtime/reconstruct/artifact-types.js +37 -0
- package/dist/core-runtime/reconstruct/benchmark-evidence.js +16 -0
- package/dist/core-runtime/reconstruct/claim-projection-validation.js +19 -5
- package/dist/core-runtime/reconstruct/competency-projection-contract.js +63 -0
- package/dist/core-runtime/reconstruct/comprehension-artifact.js +321 -0
- package/dist/core-runtime/reconstruct/comprehension-reduce.js +367 -0
- package/dist/core-runtime/reconstruct/comprehension-semantic-map.js +771 -0
- package/dist/core-runtime/reconstruct/contract-registry.js +20 -0
- package/dist/core-runtime/reconstruct/directive-validation.js +4 -3
- package/dist/core-runtime/reconstruct/dispatch-fallback-artifacts.js +640 -0
- package/dist/core-runtime/reconstruct/execution-telemetry.js +84 -9
- package/dist/core-runtime/reconstruct/final-output-sections.js +89 -0
- package/dist/core-runtime/reconstruct/governing-snapshot.js +36 -1
- package/dist/core-runtime/reconstruct/leaf-reader.js +305 -0
- package/dist/core-runtime/reconstruct/llm-dispatch-failure.js +270 -0
- package/dist/core-runtime/reconstruct/llm-touch-fingerprint.js +94 -0
- package/dist/core-runtime/reconstruct/material-admission-validation.js +20 -5
- package/dist/core-runtime/reconstruct/material-profile-validation.js +24 -6
- package/dist/core-runtime/reconstruct/materialize-preparation.js +380 -23
- package/dist/core-runtime/reconstruct/maturation-validation.js +1522 -67
- package/dist/core-runtime/reconstruct/mock-llm-realization.js +120 -1
- package/dist/core-runtime/reconstruct/obligation-assertion.js +18 -0
- package/dist/core-runtime/reconstruct/ontology-seed-validation.js +39 -7
- package/dist/core-runtime/reconstruct/output-budget.js +6 -0
- package/dist/core-runtime/reconstruct/pipeline-execution-ledger.js +112 -5
- package/dist/core-runtime/reconstruct/post-seed-validation.js +122 -11
- package/dist/core-runtime/reconstruct/proof-authority-validation.js +2 -6
- package/dist/core-runtime/reconstruct/purpose-authority-validation.js +119 -7
- package/dist/core-runtime/reconstruct/record.js +49 -3
- package/dist/core-runtime/reconstruct/registry-verification-validation.js +41 -5
- package/dist/core-runtime/reconstruct/run-control-validation.js +1019 -37
- package/dist/core-runtime/reconstruct/run.js +5409 -517
- package/dist/core-runtime/reconstruct/seed-authoring-readiness-validation.js +56 -8
- package/dist/core-runtime/reconstruct/semantic-quality-gate.js +112 -7
- package/dist/core-runtime/reconstruct/source-observation-delta-validation.js +53 -5
- package/dist/core-runtime/reconstruct/source-observations.js +166 -0
- package/dist/core-runtime/reconstruct/source-safety-validation.js +77 -188
- package/dist/core-runtime/reconstruct/source-scout-pack-validation.js +50 -6
- package/dist/core-runtime/reconstruct/terminal-validation.js +145 -13
- package/dist/core-runtime/review/continuation-plan.js +12 -5
- package/dist/core-runtime/review/issue-artifact-runtime.js +4 -1
- package/dist/core-runtime/review/materializers.js +100 -11
- package/dist/core-runtime/review/nesting-batch.js +6 -2
- package/dist/core-runtime/review/obligation-shardability.js +138 -0
- package/dist/core-runtime/review/pipeline-execution-ledger.js +42 -2
- package/dist/core-runtime/review/review-artifact-utils.js +219 -7
- package/dist/core-runtime/review/review-execution-steps.js +32 -8
- package/dist/core-runtime/review/review-progress-contract.js +8 -0
- package/dist/core-runtime/review/review-prompt-budget.js +97 -0
- package/dist/core-runtime/review/semantic-quality-gate.js +33 -7
- package/dist/core-runtime/review/spreadsheet-review-disposition.js +94 -0
- package/dist/core-runtime/route-identity.js +182 -0
- package/dist/core-runtime/spreadsheet-header-escalation.js +204 -0
- package/dist/core-runtime/spreadsheet-structure-observer.js +2689 -0
- package/dist/core-runtime/target-material-kind.js +58 -0
- package/dist/mcp/server.js +27 -5
- package/dist/mcp/tool-schemas.js +8 -0
- package/dist/tui/app.js +167 -0
- package/dist/tui/data/event-follower.js +116 -0
- package/dist/tui/data/node-detail.js +42 -0
- package/dist/tui/data/projection-poll.js +26 -0
- package/dist/tui/data/session-discovery.js +65 -0
- package/dist/tui/index.js +108 -0
- package/dist/tui/screens/log.js +25 -0
- package/dist/tui/screens/node-detail.js +17 -0
- package/dist/tui/screens/session-selector.js +26 -0
- package/dist/tui/screens/workflow-tree.js +90 -0
- package/dist/tui/view-model/reconstruct-adapter.js +162 -0
- package/dist/tui/view-model/review-adapter.js +270 -0
- package/dist/tui/view-model/tree-view-model.js +19 -0
- package/package.json +20 -3
- 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
|
+
}
|