gangtise-openapi-cli 0.34.1 → 0.36.0
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/README.md +41 -12
- package/dist/src/cli.js +4 -268
- package/dist/src/core/args.js +45 -149
- package/dist/src/core/asyncContent.js +0 -22
- package/dist/src/core/auth.js +0 -14
- package/dist/src/core/client.js +0 -151
- package/dist/src/core/commandBodies.js +10 -38
- package/dist/src/core/download.js +0 -39
- package/dist/src/core/endpoints.js +0 -31
- package/dist/src/core/errors.js +53 -86
- package/dist/src/core/fileParse.js +0 -16
- package/dist/src/core/indicatorMatrix.js +0 -207
- package/dist/src/core/normalize.js +0 -14
- package/dist/src/core/output.js +0 -56
- package/dist/src/core/printer.js +0 -19
- package/dist/src/core/quoteSharding.js +0 -58
- package/dist/src/core/titleCache.js +0 -71
- package/dist/src/core/transport.js +0 -45
- package/dist/src/version.js +1 -2
- package/gangtise-openapi/SKILL.md +10 -7
- package/gangtise-openapi/references/commands/ai.md +3 -1
- package/gangtise-openapi/references/commands/indicator.md +23 -3
- package/gangtise-openapi/references/commands/insight.md +26 -5
- package/gangtise-openapi/references/examples.md +2 -1
- package/package.json +1 -1
|
@@ -1,32 +1,14 @@
|
|
|
1
1
|
import { screenerExpressionFields, screenerExpressionIsEvaluable } from "./args.js";
|
|
2
2
|
import { ApiError, attachEnvelopeTraceId, ENVELOPE_TRACE_ID } from "./errors.js";
|
|
3
|
-
// The EDE endpoints double-wrap on success: the shared client strips the outer
|
|
4
|
-
// envelope but leaves an inner { code, status, data } around the real payload.
|
|
5
|
-
// Peel that inner envelope so the list (search) / matrix (cross-section,
|
|
6
|
-
// time-series) is reachable. A failure code carried only by the inner envelope
|
|
7
|
-
// must still surface instead of rendering its null payload as success.
|
|
8
3
|
export function unwrapIndicatorData(raw) {
|
|
9
4
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
10
5
|
const record = raw;
|
|
11
6
|
if ("code" in record || "status" in record) {
|
|
12
7
|
const code = record.code === undefined ? undefined : String(record.code);
|
|
13
8
|
const ok = record.status === true || code === "000000" || code === "0";
|
|
14
|
-
// A failure envelope may omit `data` entirely ({ code, status: false, msg })
|
|
15
|
-
// — gating on the data key would let a permission/quota error flow through
|
|
16
|
-
// as "successful" payload. Still require some envelope evidence
|
|
17
|
-
// (status/msg/data) so a non-envelope object that merely carries a `code`
|
|
18
|
-
// field can't be misread as a failure.
|
|
19
9
|
if (!ok && ("status" in record || "msg" in record || "data" in record)) {
|
|
20
|
-
// Pass `record` as details: the inner envelope carries no traceId of its
|
|
21
|
-
// own, but unwrapEnvelope attached the outer one to this object and
|
|
22
|
-
// ApiError.traceId falls back to it. Without it these failures — the EDE
|
|
23
|
-
// 999999 / 130001 that most need reporting — reach the user trace-less.
|
|
24
10
|
throw new ApiError(typeof record.msg === "string" && record.msg ? record.msg : "Indicator API request failed", code, undefined, record);
|
|
25
11
|
}
|
|
26
|
-
// Carry the envelope's traceId onto the inner payload. `unwrapEnvelope`
|
|
27
|
-
// attached it to THIS object, but everything downstream (the flatteners'
|
|
28
|
-
// shape guards) only ever sees `record.data` — without this hand-off those
|
|
29
|
-
// failures, exactly the ones support needs to trace, arrive trace-less.
|
|
30
12
|
if (ok && "data" in record)
|
|
31
13
|
return attachEnvelopeTraceId(record.data, record[ENVELOPE_TRACE_ID] ?? record.traceId);
|
|
32
14
|
}
|
|
@@ -36,12 +18,6 @@ export function unwrapIndicatorData(raw) {
|
|
|
36
18
|
function asStringArray(value) {
|
|
37
19
|
return Array.isArray(value) ? value.map((item) => String(item)) : undefined;
|
|
38
20
|
}
|
|
39
|
-
/** An IDENTITY axis — the security codes a row belongs to, the dates a column
|
|
40
|
-
* belongs to. `String(item)` would turn a `null` into the literal `"null"` and
|
|
41
|
-
* render it as a perfectly plausible label (probed 2026-08-02: `dates: [null]`
|
|
42
|
-
* produced `date: "null"`, `securityCodeList: [null]` produced
|
|
43
|
-
* `security: "null"`), so a fabricated identity would reach the output at exit
|
|
44
|
-
* 0. Nothing here may be coerced: every entry must already be a non-empty string. */
|
|
45
21
|
function asIdentityArray(data, value, key) {
|
|
46
22
|
if (!Array.isArray(value))
|
|
47
23
|
return undefined;
|
|
@@ -51,9 +27,6 @@ function asIdentityArray(data, value, key) {
|
|
|
51
27
|
}
|
|
52
28
|
return value;
|
|
53
29
|
}
|
|
54
|
-
/** Indicator metadata is an identity axis too: `--key-by code` addresses columns
|
|
55
|
-
* by `code`, so an entry without one cannot be mapped back to what was asked
|
|
56
|
-
* for. A non-object entry used to collapse into `{}` and surface as `col0`. */
|
|
57
30
|
function asIndicatorMetaList(data, value) {
|
|
58
31
|
if (!Array.isArray(value))
|
|
59
32
|
return undefined;
|
|
@@ -71,11 +44,6 @@ function rowOf(values, index) {
|
|
|
71
44
|
const row = values[index];
|
|
72
45
|
return Array.isArray(row) ? row : undefined;
|
|
73
46
|
}
|
|
74
|
-
// Build one column header per series. Prefer the human-readable name; on a
|
|
75
|
-
// duplicate name append a disambiguator so a column is never silently
|
|
76
|
-
// overwritten. `reserved` pre-seeds the metadata column names, so a series
|
|
77
|
-
// literally named "date" / "security" / "name" gets suffixed rather than
|
|
78
|
-
// clobbering the metadata.
|
|
79
47
|
function buildHeaders(bases, suffixes, reserved) {
|
|
80
48
|
const used = new Set(reserved);
|
|
81
49
|
const headers = [];
|
|
@@ -93,21 +61,10 @@ function buildHeaders(bases, suffixes, reserved) {
|
|
|
93
61
|
}
|
|
94
62
|
return headers;
|
|
95
63
|
}
|
|
96
|
-
// Column headers for a list of indicator metadata. keyBy "code" makes each
|
|
97
|
-
// column its `code` (unique, and independent of the display name or the
|
|
98
|
-
// server's column order) instead of the human name — required for batch
|
|
99
|
-
// code→value mapping, where names collide (many indicators share a display
|
|
100
|
-
// name) and the server reorders columns relative to the request. The screener
|
|
101
|
-
// disambiguates by `field` instead, because there the SAME code may legitimately
|
|
102
|
-
// appear twice under two variables with different parameters.
|
|
103
64
|
function indicatorHeaders(indicators, keyBy, reserved) {
|
|
104
65
|
const codes = indicators.map((meta) => optionalString(meta.code));
|
|
105
66
|
const fields = indicators.map((meta, i) => optionalString(meta.field) ?? codes[i]);
|
|
106
67
|
const bases = keyBy === "code" ? codes : indicators.map((meta) => optionalString(meta.name));
|
|
107
|
-
// On a screener payload every column carries its variable, so a repeated base
|
|
108
|
-
// can suffix ALL of its occurrences instead of leaving the first one bare.
|
|
109
|
-
// A bare `收盘价` next to `收盘价 (F2)` reads as "the" close price when it is
|
|
110
|
-
// really just whichever variable the server happened to list first.
|
|
111
68
|
const screener = indicators.some((meta) => meta.field !== undefined);
|
|
112
69
|
if (!screener)
|
|
113
70
|
return buildHeaders(bases, fields, reserved);
|
|
@@ -115,32 +72,6 @@ function indicatorHeaders(indicators, keyBy, reserved) {
|
|
|
115
72
|
return buildHeaders(bases.map((base, i) => (base !== undefined && repeated.has(base) ? `${base} (${fields[i] ?? i})` : base)), fields, reserved);
|
|
116
73
|
}
|
|
117
74
|
const CROSS_SECTION_COLUMNS = ["security", "name"];
|
|
118
|
-
/** A response carrying neither securities nor indicators: the query as a whole
|
|
119
|
-
* resolved to nothing. This is NOT a dropped axis, so it must not be reported as
|
|
120
|
-
* partial — calling every requested code "omitted" would be false metadata, the
|
|
121
|
-
* diff against the request is total by construction.
|
|
122
|
-
*
|
|
123
|
-
* What it MEANS changed on 2026-08-07: a genuine no-data answer (non-trading
|
|
124
|
-
* date, uncovered market, future date) now comes back as a null cell with its
|
|
125
|
-
* row and column intact, so an all-empty matrix no longer means "no data". It
|
|
126
|
-
* now means nothing in the request RESOLVED — every security code or every
|
|
127
|
-
* indicator code was unrecognised — or a parameter name is wrong. The caller's
|
|
128
|
-
* stderr note says exactly that.
|
|
129
|
-
*
|
|
130
|
-
* The check covers every STRUCTURAL array, not just the two axis lists, because
|
|
131
|
-
* accepting anything looser would let a malformed payload — `values: null`, a
|
|
132
|
-
* missing `values`, or dates with no matrix — pass as "legitimately empty" and
|
|
133
|
-
* exit 0, which is precisely the protocol regression this release's shape guards
|
|
134
|
-
* exist to catch. Required empty: `securityCodeList`, `indicatorList`, `values`;
|
|
135
|
-
* plus `dates` when present. Probed 2026-08-02 (back when this shape still meant
|
|
136
|
-
* "no data"): a time-series all-empty answer is five empty arrays, a
|
|
137
|
-
* cross-section one is four (it carries no `dates` key at all), hence "absent or
|
|
138
|
-
* empty" rather than a flat count. The STRUCTURE is unchanged; only its meaning
|
|
139
|
-
* moved — since 2026-08-07 it signals an unresolved axis, not missing data.
|
|
140
|
-
*
|
|
141
|
-
* `securityNameList` is deliberately NOT checked: it holds display labels, not
|
|
142
|
-
* structure, so a `null` there cannot misalign anything — and rejecting it would
|
|
143
|
-
* re-introduce the false-partial this function exists to prevent. */
|
|
144
75
|
export function isEmptyMatrix(data) {
|
|
145
76
|
if (!data || typeof data !== "object")
|
|
146
77
|
return false;
|
|
@@ -151,28 +82,12 @@ export function isEmptyMatrix(data) {
|
|
|
151
82
|
return false;
|
|
152
83
|
if (!Array.isArray(d.values) || d.values.length !== 0)
|
|
153
84
|
return false;
|
|
154
|
-
// `dates` is time-series only; when present it must be empty too.
|
|
155
85
|
return d.dates === undefined || (Array.isArray(d.dates) && d.dates.length === 0);
|
|
156
86
|
}
|
|
157
|
-
/** Validate the variable bindings a screener answered with, returning the bound
|
|
158
|
-
* variables that are merely missing (see below).
|
|
159
|
-
*
|
|
160
|
-
* The screener answers with the variable each column was requested under, and
|
|
161
|
-
* that binding is the ONLY thing tying a column back to the filter it came from
|
|
162
|
-
* — nothing else in the payload can catch it going wrong. A swapped or unknown
|
|
163
|
-
* `field` renders as a perfectly ordinary result (probed 2026-08-02: a response
|
|
164
|
-
* labelling a requested `F1` as `F9` printed a normal table at exit 0), which
|
|
165
|
-
* would make every screening decision downstream unfounded.
|
|
166
|
-
*
|
|
167
|
-
* Every returned entry must therefore name a REQUESTED variable and carry that
|
|
168
|
-
* variable's code, and no variable may appear twice. A wholly empty result binds
|
|
169
|
-
* nothing and is left alone. */
|
|
170
87
|
export function checkScreenerBindings(data, requested, expression) {
|
|
171
88
|
if (!data || typeof data !== "object")
|
|
172
89
|
return [];
|
|
173
90
|
const d = data;
|
|
174
|
-
// A result with no securities matched nothing and binds nothing — the
|
|
175
|
-
// canonical empty answer, left alone.
|
|
176
91
|
if (!Array.isArray(d.securityCodeList) || d.securityCodeList.length === 0)
|
|
177
92
|
return [];
|
|
178
93
|
const indicators = Array.isArray(d.indicatorList) ? d.indicatorList : [];
|
|
@@ -192,43 +107,13 @@ export function checkScreenerBindings(data, requested, expression) {
|
|
|
192
107
|
}
|
|
193
108
|
seen.add(field);
|
|
194
109
|
}
|
|
195
|
-
// Securities came back, so the filter ran. A column vanishing is never "it got
|
|
196
|
-
// filtered out" — filtering removes securities (rows), never indicators
|
|
197
|
-
// (columns). Since 2026-08-07 a covered-but-empty indicator keeps a null
|
|
198
|
-
// column, so an absent column means the server did not resolve that indicator
|
|
199
|
-
// code at all. What that implies depends on how the expression combines terms:
|
|
200
|
-
//
|
|
201
|
-
// The answer follows the expression's BOOLEAN STRUCTURE, not the mere presence
|
|
202
|
-
// of a `||` — see screenerExpressionIsEvaluable. If no branch survives the
|
|
203
|
-
// missing columns, the rows cannot be shown to satisfy anything and the result
|
|
204
|
-
// is fatal; if some branch still could have matched, the absence proves nothing
|
|
205
|
-
// wrong and it degrades to partial.
|
|
206
110
|
const missing = [...wanted.keys()].filter((field) => !seen.has(field));
|
|
207
111
|
if (!screenerExpressionIsEvaluable(expression, seen)) {
|
|
208
112
|
const absent = [...new Set(screenerExpressionFields(expression))].filter((field) => !seen.has(field));
|
|
209
113
|
throw new ApiError(`Screener binding mismatch: the expression cannot be evaluated from what came back — ${absent.join(", ")} ${absent.length > 1 ? "have" : "has"} no column, and no branch of the expression survives without ${absent.length > 1 ? "them" : "it"}, so the rows cannot be shown to satisfy it`, undefined, undefined, data);
|
|
210
114
|
}
|
|
211
|
-
// Whatever survives is an output column that went missing: information lost,
|
|
212
|
-
// correctness intact.
|
|
213
115
|
return missing;
|
|
214
116
|
}
|
|
215
|
-
/** What the caller asked for that the response does not contain.
|
|
216
|
-
*
|
|
217
|
-
* The server used to drop any axis it had no DATA for, which made this a
|
|
218
|
-
* coverage check. Re-probed 2026-08-08: a coverage gap is now padded with `null`
|
|
219
|
-
* and keeps its row and column, down to the 1×1 case (`finc_pb_mrq` × 09992.HK,
|
|
220
|
-
* `mgn_bal` × 00700.HK — null, present, alone in the request). What still
|
|
221
|
-
* disappears is a code the server cannot RESOLVE: an unknown indicator code, or
|
|
222
|
-
* a security code with the wrong market suffix (`AAPL.US` vanishes, `AAPL.O`
|
|
223
|
-
* returns).
|
|
224
|
-
*
|
|
225
|
-
* So the remaining shape is the dangerous one: a misspelled code is invisible
|
|
226
|
-
* otherwise — `--key-by code` batch mapping finds no key at all rather than a
|
|
227
|
-
* null, and the pull quietly returns fewer rows than requested with exit code 0.
|
|
228
|
-
*
|
|
229
|
-
* Universe entries with no `.` are skipped — those are sector IDs, which the
|
|
230
|
-
* server expands into constituents, so their absence from the response is
|
|
231
|
-
* expected rather than a dropped row. */
|
|
232
117
|
export function droppedFromMatrix(data, requestedSecurities, requestedIndicators) {
|
|
233
118
|
const empty = { securities: [], indicators: [] };
|
|
234
119
|
if (!data || typeof data !== "object")
|
|
@@ -241,11 +126,6 @@ export function droppedFromMatrix(data, requestedSecurities, requestedIndicators
|
|
|
241
126
|
indicators: requestedIndicators.filter((code) => !returnedIndicators.has(code)),
|
|
242
127
|
};
|
|
243
128
|
}
|
|
244
|
-
/** True only when the payload carries NONE of the EDE matrix keys — a foreign
|
|
245
|
-
* shape. Nothing legitimately reaches the flatteners in that state — `indicator
|
|
246
|
-
* search` prints its unwrapped list directly and `raw call` bypasses them — so
|
|
247
|
-
* this is a protocol failure, not a payload to hand back: returning it would
|
|
248
|
-
* print the raw envelope and exit 0, indistinguishable from success. */
|
|
249
129
|
function assertMatrixPayload(data, d) {
|
|
250
130
|
const bare = d.securityCodeList === undefined && d.securityNameList === undefined
|
|
251
131
|
&& d.indicatorList === undefined && d.dates === undefined && d.values === undefined;
|
|
@@ -258,19 +138,6 @@ function assertAxis(data, axis, key) {
|
|
|
258
138
|
throw new ApiError(`Indicator matrix shape mismatch: the response is missing \`${key}\` or it is not an array — the response layout may have changed`, undefined, undefined, data);
|
|
259
139
|
}
|
|
260
140
|
}
|
|
261
|
-
/** Resolve the display names for a security axis, degrading instead of failing.
|
|
262
|
-
*
|
|
263
|
-
* Names are consumed POSITIONALLY, so a list that does not line up cannot be
|
|
264
|
-
* trusted at all: `["泡泡玛特"]` against `["600519.SH","09992.HK"]` labels 茅台's
|
|
265
|
-
* series 泡泡玛特, and a `[null]` element used to render a column literally
|
|
266
|
-
* headed `"null"` (both probed 2026-08-02). Neither may reach the output.
|
|
267
|
-
*
|
|
268
|
-
* But a name is a LABEL, not structure — `securityCodeList` already carries
|
|
269
|
-
* identity, and every header falls back to the code. So an anomaly here drops
|
|
270
|
-
* the names and keeps the values, rather than killing an EDE command whose
|
|
271
|
-
* numbers are all correct. That is a deliberate asymmetry with the other guards
|
|
272
|
-
* in this module: they protect against MISATTRIBUTED VALUES and must be fatal;
|
|
273
|
-
* this one protects a caption. */
|
|
274
141
|
function resolveSecurityNames(names, codes) {
|
|
275
142
|
if (names === undefined || names === null)
|
|
276
143
|
return undefined;
|
|
@@ -282,9 +149,6 @@ function resolveSecurityNames(names, codes) {
|
|
|
282
149
|
return names.map((name, i) => {
|
|
283
150
|
if (typeof name === "string" && name.trim() !== "")
|
|
284
151
|
return name;
|
|
285
|
-
// A blank or null name is a plausible gap for one security — fall back
|
|
286
|
-
// quietly. A non-string (an object, a number) means the field changed type,
|
|
287
|
-
// which is worth saying once.
|
|
288
152
|
if (typeof name !== "string" && name !== null && name !== undefined && !warned) {
|
|
289
153
|
warned = true;
|
|
290
154
|
process.stderr.write(`[gangtise] warning: securityNameList holds non-string entries; those columns fall back to the security code.\n`);
|
|
@@ -292,13 +156,6 @@ function resolveSecurityNames(names, codes) {
|
|
|
292
156
|
return codes[i];
|
|
293
157
|
});
|
|
294
158
|
}
|
|
295
|
-
/** The matrix endpoints cannot legitimately answer with a non-object payload:
|
|
296
|
-
* `indicator search` never reaches the flatteners (it prints the unwrapped list
|
|
297
|
-
* directly) and `raw call` bypasses them entirely, so there is no caller for
|
|
298
|
-
* which `null`, an array, or a foreign object is a valid cross-section /
|
|
299
|
-
* time-series / screener body. Checked here, BEFORE the envelope is discarded,
|
|
300
|
-
* because a `data: null` cannot carry the non-enumerable traceId — passing the
|
|
301
|
-
* envelope as details is the only way such a failure stays traceable. */
|
|
302
159
|
export function requireIndicatorMatrix(raw) {
|
|
303
160
|
const data = unwrapIndicatorData(raw);
|
|
304
161
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
@@ -311,23 +168,6 @@ function assertValuesPresent(data, values) {
|
|
|
311
168
|
throw new ApiError(`Indicator matrix shape mismatch: the response carries axis lists but no \`values\` array — the response layout may have changed`, undefined, undefined, data);
|
|
312
169
|
}
|
|
313
170
|
}
|
|
314
|
-
/** The matrix and its axis labels must agree on BOTH dimensions, or cells are
|
|
315
|
-
* dropped or misread. The 2026-08-01 revision transposed cross-section without a
|
|
316
|
-
* version marker, so a re-transpose has to fail loudly rather than silently
|
|
317
|
-
* relabel columns — with the caveat that this only catches a NON-SQUARE change.
|
|
318
|
-
* A 2×2 or 1×1 matrix keeps its dimensions when transposed and would still be
|
|
319
|
-
* read with the axes swapped; nothing in the payload distinguishes the two.
|
|
320
|
-
*
|
|
321
|
-
* Row length is checked exactly, not leniently: the server pads a row with
|
|
322
|
-
* `null` rather than truncating it — probed 2026-08-02 across A/HK/US, where a
|
|
323
|
-
* US security missing 3 of 4 indicators still came back with a full-length row,
|
|
324
|
-
* and a cross-market time series padded every security to the union of trading
|
|
325
|
-
* days (each market's own holidays land as `null`). A short or long row is
|
|
326
|
-
* therefore a structural change, not missing data.
|
|
327
|
-
*
|
|
328
|
-
* `data` rides along as ApiError details so the failure keeps the response's
|
|
329
|
-
* traceId — a shape mismatch is precisely the kind of thing support needs to
|
|
330
|
-
* trace, and without it the error reaches the user trace-less. */
|
|
331
171
|
function assertMatrixShape(data, values, rows, rowAxis, cols, colAxis) {
|
|
332
172
|
if (values.length !== rows) {
|
|
333
173
|
throw new ApiError(`Indicator matrix shape mismatch: got ${values.length} value rows for ${rows} ${rowAxis} — the response layout may have changed`, undefined, undefined, data);
|
|
@@ -339,19 +179,12 @@ function assertMatrixShape(data, values, rows, rowAxis, cols, colAxis) {
|
|
|
339
179
|
}
|
|
340
180
|
}
|
|
341
181
|
}
|
|
342
|
-
// Cross-section (and the screener, whose payload is the same shape plus a
|
|
343
|
-
// per-indicator `field`): one row per security, one column per indicator.
|
|
344
|
-
// `values` is [security][indicator], so security i's value for indicator j is
|
|
345
|
-
// values[i][j]. There is no row-level date any more — the query date now lives
|
|
346
|
-
// in each indicator's own parameters and may legitimately differ per column.
|
|
347
182
|
export function flattenCrossSection(data, keyBy = "name") {
|
|
348
183
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
349
184
|
throw new ApiError(`Indicator API returned no matrix object (got ${data === null ? "null" : Array.isArray(data) ? "an array" : typeof data}) — the response layout may have changed`, undefined, undefined, data);
|
|
350
185
|
}
|
|
351
186
|
const d = data;
|
|
352
187
|
assertMatrixPayload(data, d);
|
|
353
|
-
// Past this point the payload claims to be a matrix, so every axis it needs
|
|
354
|
-
// must actually be there. A `null` or absent axis is a broken response.
|
|
355
188
|
const securityCode = asIdentityArray(data, d.securityCodeList, "securityCodeList");
|
|
356
189
|
const indicators = asIndicatorMetaList(data, d.indicatorList);
|
|
357
190
|
assertAxis(data, securityCode, "securityCodeList");
|
|
@@ -361,8 +194,6 @@ export function flattenCrossSection(data, keyBy = "name") {
|
|
|
361
194
|
const securityName = resolveSecurityNames(d.securityNameList, securityCode);
|
|
362
195
|
const headers = indicatorHeaders(indicators, keyBy, CROSS_SECTION_COLUMNS);
|
|
363
196
|
const list = securityCode.map((code, i) => {
|
|
364
|
-
// Only create `name` when there IS one: an always-present `name: undefined`
|
|
365
|
-
// is invisible in JSON but renders as a real empty column in CSV/table.
|
|
366
197
|
const row = securityName ? { security: code, name: securityName[i] } : { security: code };
|
|
367
198
|
const cells = rowOf(d.values, i);
|
|
368
199
|
for (let j = 0; j < headers.length; j++) {
|
|
@@ -372,25 +203,6 @@ export function flattenCrossSection(data, keyBy = "name") {
|
|
|
372
203
|
});
|
|
373
204
|
return { list, total: list.length };
|
|
374
205
|
}
|
|
375
|
-
// Time-series: one row per date. Columns are the indicators (single-security
|
|
376
|
-
// case) or the securities (single-indicator case) — exactly one dimension
|
|
377
|
-
// varies, per the API contract. `values` stays a 2D [series][date] matrix.
|
|
378
|
-
//
|
|
379
|
-
// Axis rule, in priority order:
|
|
380
|
-
// 1. More than one indicator came back → multi-indicator × single-security.
|
|
381
|
-
// 2. More than one security came back → single-indicator × multi-security.
|
|
382
|
-
// This is what a sector ID produces: the request carries ONE universe entry
|
|
383
|
-
// and the server expands it into N constituents, so the request count says
|
|
384
|
-
// nothing about the axis.
|
|
385
|
-
// 3. Both are 1 → genuinely ambiguous, so fall back to what was REQUESTED:
|
|
386
|
-
// - a universe holding a sector ID always takes the security axis. The
|
|
387
|
-
// sector may expand to one constituent, or the rest may have been dropped
|
|
388
|
-
// for lack of data; either way the caller asked "which securities", and an
|
|
389
|
-
// indicator-named column would erase whose series this is.
|
|
390
|
-
// - otherwise the entry count decides. The server drops securities with no
|
|
391
|
-
// data at all, so a two-security request that comes back with one must
|
|
392
|
-
// still be labelled by security (probed 2026-08-02: `finc_pe_ttm` over
|
|
393
|
-
// 600519.SH + 09992.HK).
|
|
394
206
|
export function flattenTimeSeries(data, keyBy = "name", requestedUniverse) {
|
|
395
207
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
396
208
|
throw new ApiError(`Indicator API returned no matrix object (got ${data === null ? "null" : Array.isArray(data) ? "an array" : typeof data}) — the response layout may have changed`, undefined, undefined, data);
|
|
@@ -405,28 +217,12 @@ export function flattenTimeSeries(data, keyBy = "name", requestedUniverse) {
|
|
|
405
217
|
assertAxis(data, indicators, "indicatorList");
|
|
406
218
|
assertValuesPresent(data, d.values);
|
|
407
219
|
const securityName = resolveSecurityNames(d.securityNameList, securityCode);
|
|
408
|
-
// The API permits multi-indicator × single-security OR single-indicator ×
|
|
409
|
-
// multi-security, never both — the server rejects such a REQUEST with 100003.
|
|
410
|
-
// A RESPONSE carrying both axes plural is therefore unattributable: whichever
|
|
411
|
-
// axis becomes the columns, the other one's identity is silently discarded
|
|
412
|
-
// (probed 2026-08-02: 2×2 rendered as 收盘价/成交量 with both securities gone,
|
|
413
|
-
// and droppedFromMatrix sees nothing missing so it would not even flag).
|
|
414
|
-
// Data with only one identity axis is unattributable in the other direction:
|
|
415
|
-
// `securityCodeList: []` alongside a populated matrix leaves every row
|
|
416
|
-
// belonging to no security at all, and the row/column counts still line up so
|
|
417
|
-
// no other guard notices.
|
|
418
|
-
// Without this, dates-with-no-series still passes the later shape check (0 rows
|
|
419
|
-
// for 0 series) and emits one row per date carrying nothing but the date. A
|
|
420
|
-
// response that resolved to nothing empties `dates` too (probed 2026-08-02), so dates
|
|
421
|
-
// alongside a missing axis is always a broken response.
|
|
422
220
|
if (dates.length > 0 && (securityCode.length === 0 || indicators.length === 0)) {
|
|
423
221
|
throw new ApiError(`Indicator matrix shape mismatch: the time-series response carries ${dates.length} dates but ${securityCode.length} securities and ${indicators.length} indicators — the data could not be attributed`, undefined, undefined, data);
|
|
424
222
|
}
|
|
425
223
|
if (securityCode.length > 1 && indicators.length > 1) {
|
|
426
224
|
throw new ApiError(`Indicator matrix shape mismatch: the time-series response carries ${securityCode.length} securities AND ${indicators.length} indicators, which the endpoint does not support — one of the two identities would be lost`, undefined, undefined, data);
|
|
427
225
|
}
|
|
428
|
-
// A universe entry without a `.` is a sector ID — the server expands it, so
|
|
429
|
-
// neither its presence nor the entry count says anything about the axis.
|
|
430
226
|
const requested = requestedUniverse === undefined ? undefined : [...new Set(requestedUniverse)];
|
|
431
227
|
const hasSector = requested?.some((entry) => !entry.includes(".")) ?? false;
|
|
432
228
|
const seriesAreIndicators = indicators.length > 1 ? true
|
|
@@ -435,9 +231,6 @@ export function flattenTimeSeries(data, keyBy = "name", requestedUniverse) {
|
|
|
435
231
|
: (requested?.length ?? securityCode.length) <= 1;
|
|
436
232
|
const seriesCount = seriesAreIndicators ? indicators.length : securityCode.length;
|
|
437
233
|
assertMatrixShape(data, d.values, seriesCount, seriesAreIndicators ? "indicators" : "securities", dates.length, "dates");
|
|
438
|
-
// Map over securityCode rather than securityNameList directly: a response that
|
|
439
|
-
// omits the names must still yield one header per security (falling back to
|
|
440
|
-
// the code), not zero columns.
|
|
441
234
|
const headers = seriesAreIndicators
|
|
442
235
|
? indicatorHeaders(indicators, keyBy, ["date"])
|
|
443
236
|
: buildHeaders(securityCode.map((code, i) => (keyBy === "code" ? code : securityName?.[i])), securityCode, ["date"]);
|
|
@@ -1,24 +1,10 @@
|
|
|
1
1
|
import { ENVELOPE_TRACE_ID, ValidationError } from "./errors.js";
|
|
2
|
-
/** 信封 traceId 由 `client.ts` 挂在每个成功响应的 payload 上(非枚举 symbol)。
|
|
3
|
-
* 结构异常报障没这个 id 服务端查不了,所以拍平失败时要一并带出。 */
|
|
4
2
|
function traceSuffix(source) {
|
|
5
3
|
if (!source || typeof source !== "object")
|
|
6
4
|
return "";
|
|
7
5
|
const traceId = source[ENVELOPE_TRACE_ID];
|
|
8
6
|
return typeof traceId === "string" ? `(trace ${traceId})` : "";
|
|
9
7
|
}
|
|
10
|
-
/** 按位置把列式响应的一行值拍平成对象。
|
|
11
|
-
*
|
|
12
|
-
* 上游对「fieldList 里有该接口不存在的字段名」有两套处理,实测 2026-07-24:
|
|
13
|
-
* day-kline / minute-kline / fund-flow 把名和值一起丢、三大报表补 null——长度仍相等,安全;
|
|
14
|
-
* 但 realtime / main-business / valuation-analysis 是**值只按有效字段返回、字段名却按请求
|
|
15
|
-
* 原样回显**。长度一旦不等,按位置拍平就会把值贴到错误的字段上:realtime 传
|
|
16
|
-
* ["securityCode","close","turnoverRate"](realtime 根本没有 close)只回 2 个值,
|
|
17
|
-
* 换手率 28.5573 被贴成 close,读起来就是「茅台收盘价 28.56」(真实价 1297.41)。
|
|
18
|
-
* 不报错、数字看着还合理、却完全是另一个指标——静默错列必须变成显式失败。
|
|
19
|
-
*
|
|
20
|
-
* 文案不能一口咬定「字段名传错」:`alternative edb-data` 走同一个拍平却根本没有
|
|
21
|
-
* `--field`(只有 `--indicator-id`),那里长度不等只可能是上游响应结构变了。 */
|
|
22
8
|
export function zipFieldRow(fields, row, source) {
|
|
23
9
|
if (row.length !== fields.length) {
|
|
24
10
|
throw new ValidationError(`响应字段数与 fieldList 不匹配(fieldList ${fields.length} 项、该行返回 ${row.length} 个值)——按位置拍平会把值贴到错误的字段上,已拒绝输出。带 --field 的命令多为传了该接口不存在的字段名(上游只返回有效字段的值、字段名却按请求回显):核对 --field 取值(如 quote realtime 没有 close,最新价是 latestPrice),不确定就不传 --field(返回全量字段最稳)。没有 --field 的命令(如 alternative edb-data)出现此错,是上游响应结构异常,请报障${traceSuffix(source)}。`);
|
package/dist/src/core/output.js
CHANGED
|
@@ -8,16 +8,9 @@ export function parseOutputFormat(value) {
|
|
|
8
8
|
}
|
|
9
9
|
throw new ConfigError(`Unsupported format: ${format}`);
|
|
10
10
|
}
|
|
11
|
-
/** Cell text for terminal-facing formats (table/markdown): newlines collapsed for
|
|
12
|
-
* alignment, remaining C0/DEL/C1 control chars stripped so server data can't inject
|
|
13
|
-
* terminal escape sequences into the user's terminal (U+009B is a one-byte CSI
|
|
14
|
-
* that 8-bit-control terminals treat exactly like ESC[). */
|
|
15
11
|
function sanitizeCell(value) {
|
|
16
12
|
return value.replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f\u007f\u0080-\u009f]/g, "");
|
|
17
13
|
}
|
|
18
|
-
/** Terminal display width: CJK/fullwidth chars and emoji occupy 2 columns — padEnd
|
|
19
|
-
* counts UTF-16 code units and misaligns every table containing Chinese text or emoji
|
|
20
|
-
* (e.g. WeChat group names). */
|
|
21
14
|
function displayWidth(value) {
|
|
22
15
|
let width = 0;
|
|
23
16
|
for (const ch of value) {
|
|
@@ -40,10 +33,6 @@ function formatScalar(value) {
|
|
|
40
33
|
}
|
|
41
34
|
return String(value);
|
|
42
35
|
}
|
|
43
|
-
/** Rows for a list: object rows as-is with stray null/scalar rows dropped (one bad
|
|
44
|
-
* row must not degrade the whole table to index/value pairs — and the streaming CSV
|
|
45
|
-
* path already skips them, so both paths agree). A list with NO object rows at all
|
|
46
|
-
* (e.g. plain string codes) still renders as index/value pairs. */
|
|
47
36
|
function rowsFromList(list) {
|
|
48
37
|
const objectRows = list.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item)));
|
|
49
38
|
if (objectRows.length > 0)
|
|
@@ -51,11 +40,6 @@ function rowsFromList(list) {
|
|
|
51
40
|
return list.map((item, index) => ({ index, value: item }));
|
|
52
41
|
}
|
|
53
42
|
function toRows(value) {
|
|
54
|
-
// A null/undefined payload means "no data", not "one row whose only column is
|
|
55
|
-
// null". Without this guard it fell through to [{ value }] and jsonl emitted
|
|
56
|
-
// {"value":null} — a phantom record that makes `wc -l` report 1. Real case:
|
|
57
|
-
// insight foreign-opinion --industry answers 200 + data:null.
|
|
58
|
-
// Scalars (0, "", false) are left alone; only the two empty values short-circuit.
|
|
59
43
|
if (value === null || value === undefined) {
|
|
60
44
|
return [];
|
|
61
45
|
}
|
|
@@ -71,10 +55,7 @@ function toRows(value) {
|
|
|
71
55
|
}
|
|
72
56
|
return [{ value }];
|
|
73
57
|
}
|
|
74
|
-
// One huge cell (a 50KB brief/chat message) would otherwise force every row of
|
|
75
|
-
// that column to be padded to the same width — rows × width of pure spaces.
|
|
76
58
|
const MAX_CELL_DISPLAY_WIDTH = 120;
|
|
77
|
-
/** Truncate a cell to the display-width cap, ellipsis included in the budget. */
|
|
78
59
|
function clampCell(value) {
|
|
79
60
|
if (displayWidth(value) <= MAX_CELL_DISPLAY_WIDTH)
|
|
80
61
|
return value;
|
|
@@ -94,12 +75,6 @@ function renderTable(rows) {
|
|
|
94
75
|
return "(empty)";
|
|
95
76
|
}
|
|
96
77
|
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
|
97
|
-
// Format every cell once (formatScalar may JSON.stringify objects), sanitizing
|
|
98
|
-
// control chars so multi-line fields don't break alignment. Reuse the matrix for
|
|
99
|
-
// both width and rendering — and compute widths with reduce, NOT Math.max(...arr):
|
|
100
|
-
// spreading a per-row array overflows the call stack on large results (table is
|
|
101
|
-
// the default format, e.g. `quote day-kline --security all`). Widths and padding
|
|
102
|
-
// use displayWidth so CJK cells stay aligned.
|
|
103
78
|
const headerCells = columns.map((column) => clampCell(sanitizeCell(column)));
|
|
104
79
|
const matrix = rows.map((row) => columns.map((column) => clampCell(sanitizeCell(formatScalar(row[column])))));
|
|
105
80
|
const widths = columns.map((_, c) => matrix.reduce((max, cells) => Math.max(max, displayWidth(cells[c])), displayWidth(headerCells[c])));
|
|
@@ -114,10 +89,6 @@ function renderMarkdown(rows) {
|
|
|
114
89
|
return "(empty)";
|
|
115
90
|
}
|
|
116
91
|
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
|
117
|
-
// Column names come from server data (e.g. EDE indicator display names) — escape
|
|
118
|
-
// them like cell values or a name containing | / , breaks the whole table.
|
|
119
|
-
// Backslash must go first: escaping only "|" turns a literal `\|` into `\\|`,
|
|
120
|
-
// which GFM reads as an escaped backslash + BARE pipe → an extra column.
|
|
121
92
|
const mdEscape = (value) => value.replaceAll("\\", "\\\\").replaceAll("|", "\\|");
|
|
122
93
|
const header = `| ${columns.map((column) => mdEscape(sanitizeCell(column))).join(" | ")} |`;
|
|
123
94
|
const divider = `| ${columns.map(() => "---").join(" | ")} |`;
|
|
@@ -134,8 +105,6 @@ function renderCsv(rows) {
|
|
|
134
105
|
return [header, ...body].join("\n");
|
|
135
106
|
}
|
|
136
107
|
export function renderOutput(value, format) {
|
|
137
|
-
// toRows is computed lazily per branch: json never needs it, and jsonl only
|
|
138
|
-
// falls back to it when the value isn't already a {list}/array.
|
|
139
108
|
switch (format) {
|
|
140
109
|
case "json":
|
|
141
110
|
return JSON.stringify(value, null, 2);
|
|
@@ -154,18 +123,14 @@ export function renderOutput(value, format) {
|
|
|
154
123
|
return renderTable(toRows(value));
|
|
155
124
|
}
|
|
156
125
|
}
|
|
157
|
-
/** Stream large jsonl/csv output row-by-row to avoid building a full string in memory. */
|
|
158
126
|
export async function streamOutputToFile(value, format, outputPath) {
|
|
159
127
|
if (format !== "jsonl" && format !== "csv")
|
|
160
128
|
return false;
|
|
161
129
|
const list = pickList(value);
|
|
162
130
|
if (!list)
|
|
163
131
|
return false;
|
|
164
|
-
// Below this row count the join() approach is cheaper than per-row writes.
|
|
165
132
|
if (list.length < 1000)
|
|
166
133
|
return false;
|
|
167
|
-
// csv can only stream object rows; an all-scalar list has no columns — fall back
|
|
168
|
-
// to renderOutput's index/value shaping instead of writing a BOM-only file.
|
|
169
134
|
let csvRows = [];
|
|
170
135
|
let csvColumns = [];
|
|
171
136
|
if (format === "csv") {
|
|
@@ -177,13 +142,8 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
177
142
|
const { dirname } = await import("node:path");
|
|
178
143
|
const { createWriteStream } = await import("node:fs");
|
|
179
144
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
180
|
-
// Stream into a .part sibling and rename over the target only on success, so a
|
|
181
|
-
// failed re-export never destroys the previous good file.
|
|
182
145
|
const partPath = `${outputPath}.part`;
|
|
183
146
|
const stream = createWriteStream(partPath, { encoding: "utf8" });
|
|
184
|
-
// A stream 'error' with no listener (EACCES on open, ENOSPC mid-write) crashes the
|
|
185
|
-
// process before any write callback fires. Swallow the event here — the failure
|
|
186
|
-
// still surfaces through the write/end callbacks below.
|
|
187
147
|
stream.on("error", () => { });
|
|
188
148
|
try {
|
|
189
149
|
if (format === "jsonl") {
|
|
@@ -192,7 +152,6 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
192
152
|
}
|
|
193
153
|
}
|
|
194
154
|
else {
|
|
195
|
-
// BOM so Excel double-click decodes Chinese as UTF-8 instead of ANSI/GBK.
|
|
196
155
|
await writeLine(stream, "\ufeff" + csvColumns.map(csvEscape).join(","));
|
|
197
156
|
for (const row of csvRows) {
|
|
198
157
|
const cells = csvColumns.map((column) => csvEscape(formatScalar(row[column])));
|
|
@@ -205,13 +164,7 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
205
164
|
await fs.rename(partPath, outputPath);
|
|
206
165
|
}
|
|
207
166
|
catch (error) {
|
|
208
|
-
// Mirror the download path: never leave a truncated file that looks complete.
|
|
209
|
-
// createWriteStream opens lazily — an early abort can reach unlink BEFORE the
|
|
210
|
-
// open() creates the file, so wait for 'close' (fd released or open aborted)
|
|
211
|
-
// or the .part would reappear right after being "removed".
|
|
212
167
|
stream.destroy();
|
|
213
|
-
// Already-closed happens when the failure was the rename (stream ended fine);
|
|
214
|
-
// then 'close' has fired and waiting for it again would hang forever.
|
|
215
168
|
if (!stream.closed) {
|
|
216
169
|
await new Promise((resolve) => stream.once("close", resolve));
|
|
217
170
|
}
|
|
@@ -220,8 +173,6 @@ export async function streamOutputToFile(value, format, outputPath) {
|
|
|
220
173
|
}
|
|
221
174
|
return true;
|
|
222
175
|
}
|
|
223
|
-
/** Extract a row array from a value: the array itself, or its `.list` property,
|
|
224
|
-
* else null. Shared by streaming, printer's title-cache, and list detection. */
|
|
225
176
|
export function pickList(value) {
|
|
226
177
|
if (Array.isArray(value))
|
|
227
178
|
return value;
|
|
@@ -234,9 +185,6 @@ export function pickList(value) {
|
|
|
234
185
|
}
|
|
235
186
|
function csvEscape(value) {
|
|
236
187
|
let out = value;
|
|
237
|
-
// Formula-injection guard, but don't mangle legitimate numbers: a leading
|
|
238
|
-
// -/+ only needs escaping when the cell isn't a finite number (e.g. "-1+cmd"),
|
|
239
|
-
// so values like "-3.5" stay numeric for Excel/pandas.
|
|
240
188
|
if (/^[=@\t\r]/.test(out) || (/^[+\-]/.test(out) && !Number.isFinite(Number(out))))
|
|
241
189
|
out = "'" + out;
|
|
242
190
|
if (/[",\n\r]/.test(out))
|
|
@@ -250,8 +198,6 @@ function writeLine(stream, line) {
|
|
|
250
198
|
resolve();
|
|
251
199
|
return;
|
|
252
200
|
}
|
|
253
|
-
// Waiting only for 'drain' would hang forever if the stream errors instead;
|
|
254
|
-
// race the two and detach the loser so listeners don't pile up per write.
|
|
255
201
|
const onDrain = () => { stream.off("error", onError); resolve(); };
|
|
256
202
|
const onError = (err) => { stream.off("drain", onDrain); reject(err); };
|
|
257
203
|
stream.once("drain", onDrain);
|
|
@@ -264,8 +210,6 @@ export async function saveOutputIfNeeded(content, outputPath) {
|
|
|
264
210
|
}
|
|
265
211
|
const { dirname } = await import("node:path");
|
|
266
212
|
await fs.mkdir(dirname(outputPath), { recursive: true });
|
|
267
|
-
// Write to a .part sibling and rename over the target only on success, so a
|
|
268
|
-
// failed re-export/download never destroys the previous good file.
|
|
269
213
|
const partPath = `${outputPath}.part`;
|
|
270
214
|
try {
|
|
271
215
|
if (typeof content === "string") {
|
package/dist/src/core/printer.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { normalizeRows } from "./normalize.js";
|
|
2
2
|
import { pickList, renderOutput, saveOutputIfNeeded, streamOutputToFile } from "./output.js";
|
|
3
3
|
import { extractTitles, writeTitleCache } from "./titleCache.js";
|
|
4
|
-
/** Rows above which renderOutput's single in-memory string risks high memory / the V8
|
|
5
|
-
* max-string-length cap. Well above normal result sizes, so it only fires on huge exports. */
|
|
6
4
|
const LARGE_RESULT_ROWS = 50_000;
|
|
7
|
-
/** Warn when we're about to renderOutput a huge result. Called only on the paths that
|
|
8
|
-
* actually render — never after streamOutputToFile streamed — so it can't misfire on a
|
|
9
|
-
* genuinely streamed export, and it DOES fire on the all-scalar-csv list that streaming
|
|
10
|
-
* declines (which then falls back to a full in-memory string). */
|
|
11
5
|
function warnIfLargeInMemory(items, format) {
|
|
12
6
|
if (items && items.length >= LARGE_RESULT_ROWS) {
|
|
13
7
|
process.stderr.write(`[gangtise] note: ${items.length} rows in '${format}' is built entirely in memory; stream large exports to a file with --format jsonl --output <path> (or csv).\n`);
|
|
@@ -28,11 +22,6 @@ export async function printData(data, format, output, cache) {
|
|
|
28
22
|
const listLen = Array.isArray(meta.list) ? meta.list.length : 0;
|
|
29
23
|
process.stderr.write(`Total: ${meta.total}, showing: ${listLen}\n`);
|
|
30
24
|
}
|
|
31
|
-
// Incomplete results exit with code 3: the table/csv/jsonl renderers only
|
|
32
|
-
// emit the rows, so without a distinct exit code a script or AI consumer
|
|
33
|
-
// cannot tell such an export from a clean one. `partial` means rows are
|
|
34
|
-
// missing — failed pages/shards, a row cap, or an EDE response that dropped
|
|
35
|
-
// whole securities/indicators because it did not recognise those codes.
|
|
36
25
|
if (meta.partial === true) {
|
|
37
26
|
process.exitCode = 3;
|
|
38
27
|
}
|
|
@@ -42,22 +31,14 @@ export async function printData(data, format, output, cache) {
|
|
|
42
31
|
process.stdout.write(`${output}\n`);
|
|
43
32
|
return;
|
|
44
33
|
}
|
|
45
|
-
// streamOutputToFile declined (non-stream format, or an all-scalar csv list) \u2192 we
|
|
46
|
-
// fall back to renderOutput, which builds the whole result as one string.
|
|
47
34
|
warnIfLargeInMemory(items, format);
|
|
48
35
|
const content = renderOutput(normalized, format);
|
|
49
|
-
// CSV files get a BOM so Excel double-click decodes Chinese as UTF-8 (stdout
|
|
50
|
-
// stays BOM-free for pipes).
|
|
51
36
|
await saveOutputIfNeeded(format === "csv" ? `\ufeff${content}` : content, output);
|
|
52
37
|
process.stdout.write(`${output}\n`);
|
|
53
38
|
return;
|
|
54
39
|
}
|
|
55
40
|
warnIfLargeInMemory(items, format);
|
|
56
41
|
const rendered = renderOutput(normalized, format);
|
|
57
|
-
// An empty render means "no rows at all". Appending the newline anyway would put
|
|
58
|
-
// one blank line on stdout — `wc -l` reports 1 and `while read` yields one empty
|
|
59
|
-
// record, which is exactly the phantom row the null-payload guard in toRows()
|
|
60
|
-
// removes. Table/markdown render "(empty)" so they are unaffected.
|
|
61
42
|
if (rendered)
|
|
62
43
|
process.stdout.write(`${rendered}\n`);
|
|
63
44
|
}
|