laterite 0.5.1 → 0.6.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/dist/index.mjs CHANGED
@@ -5,10 +5,12 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // ts/index.ts
8
+ import { readFileSync as readFileSync2 } from "fs";
8
9
  import { tableFromArrays, tableToIPC } from "apache-arrow";
9
10
 
10
11
  // ts/ags4-file.ts
11
- import { writeFileSync } from "fs";
12
+ import { existsSync, readFileSync, statSync, writeFileSync } from "fs";
13
+ import { resolve } from "path";
12
14
  import { tableFromIPC as tableFromIPC2 } from "apache-arrow";
13
15
 
14
16
  // ts/duckdb.ts
@@ -48,6 +50,12 @@ var BadDictError = class extends Ags4Error {
48
50
  this.name = "BadDictError";
49
51
  }
50
52
  };
53
+ var StaleCertError = class extends Ags4Error {
54
+ constructor(message, exitCode = 4) {
55
+ super(message, exitCode);
56
+ this.name = "StaleCertError";
57
+ }
58
+ };
51
59
  function makeError(kind, exitCode, message) {
52
60
  switch (kind) {
53
61
  case "not_found":
@@ -83,6 +91,10 @@ function fromNativeError(e) {
83
91
  function quoteId(name) {
84
92
  return `"${name.replace(/"/g, '""')}"`;
85
93
  }
94
+ function stripSynthKeys(table) {
95
+ const dataCols = table.schema.fields.filter((f) => !f.name.startsWith("_")).map((f) => f.name);
96
+ return dataCols.length === table.numCols ? table : table.select(dataCols);
97
+ }
86
98
  var DUCK_PACKAGE = "@duckdb/node-api";
87
99
  var duckMod = null;
88
100
  function loadDuck() {
@@ -122,11 +134,19 @@ var DuckEngine = class _DuckEngine {
122
134
  * rows appended from the born-typed arrow-js Table). Idempotent per code. */
123
135
  async register(code, meta, table) {
124
136
  if (this.registered.has(code)) return;
125
- const cols = meta.headings.map((h, i) => `${quoteId(h)} ${meta.sqlTypes[i]}`).join(", ");
137
+ const keyCols = table.schema.fields.some((f) => f.name === "_id") ? ["_id", "_parent_id"] : [];
138
+ const cols = [
139
+ ...keyCols.map((k) => `${quoteId(k)} VARCHAR`),
140
+ ...meta.headings.map((h, i) => `${quoteId(h)} ${meta.sqlTypes[i]}`)
141
+ ].join(", ");
126
142
  await this.#con.run(`CREATE TABLE ${quoteId(code)} (${cols})`);
127
143
  const appender = await this.#con.createAppender(code);
144
+ const keyVectors = keyCols.map((k) => table.getChild(k));
128
145
  const vectors = meta.headings.map((h) => table.getChild(h));
129
146
  for (let r = 0; r < table.numRows; r++) {
147
+ for (let c = 0; c < keyCols.length; c++) {
148
+ this.#appendCell(appender, "VARCHAR", keyVectors[c]?.get(r));
149
+ }
130
150
  for (let c = 0; c < meta.headings.length; c++) {
131
151
  this.#appendCell(appender, meta.sqlTypes[c] ?? "VARCHAR", vectors[c]?.get(r));
132
152
  }
@@ -196,6 +216,100 @@ function toMicros(value) {
196
216
  return BigInt(Math.round(ms * 1e3));
197
217
  }
198
218
 
219
+ // ts/native.ts
220
+ import {
221
+ version,
222
+ parseArrow,
223
+ runCheck,
224
+ fixFile,
225
+ listRules,
226
+ registryDictionaryJson,
227
+ diff,
228
+ emitAgs4FromIpc,
229
+ Reading,
230
+ Sidecar,
231
+ canonicalType,
232
+ displayHint,
233
+ parseValue,
234
+ transportPack,
235
+ transportUnpack,
236
+ transportLock,
237
+ transportUnlock,
238
+ ags4ToExcel,
239
+ excelToAgs4
240
+ } from "#native";
241
+
242
+ // ts/report.ts
243
+ var Report = class _Report {
244
+ #r;
245
+ constructor(r) {
246
+ this.#r = r;
247
+ }
248
+ /** Synthesise a clean report from a fresh certificate — the engine-skipped
249
+ * outcome of `Ags4File.validate()` on an `index=`-certified file. `resolution`
250
+ * is the sentinel `"certified"` (the engine never emits it), `count` is 0, and
251
+ * the edition is the cert's. Mirrors laterite-py's `Report.from_cert`. */
252
+ static fromCert(cert, label) {
253
+ return new _Report({
254
+ ok: true,
255
+ exitCode: 0,
256
+ file: label,
257
+ dictVersion: cert.edition,
258
+ resolution: "certified",
259
+ count: 0,
260
+ findings: [],
261
+ json: JSON.stringify({ file: label, findings: {} }),
262
+ ndjson: ""
263
+ });
264
+ }
265
+ get file() {
266
+ return this.#r.file;
267
+ }
268
+ get dictVersion() {
269
+ return this.#r.dictVersion;
270
+ }
271
+ get resolution() {
272
+ return this.#r.resolution;
273
+ }
274
+ get count() {
275
+ return this.#r.count;
276
+ }
277
+ /** `true` iff there are zero findings (distinct from the native `ok`, which
278
+ * only means "validatable"). */
279
+ get isValid() {
280
+ return this.#r.count === 0;
281
+ }
282
+ get exitCode() {
283
+ return this.#r.exitCode;
284
+ }
285
+ /** All findings, in `lat-check` order: `{rule, line?, group, desc, severity?}`. */
286
+ get findings() {
287
+ return this.#r.findings;
288
+ }
289
+ /** `{ "AGS Format Rule N": [{line?, group, desc, …}] }` — the spec-rule
290
+ * grouping (mirrors `Report.by_rule`). */
291
+ byRule() {
292
+ const out = {};
293
+ for (const f of this.#r.findings) {
294
+ const { rule, ...rest } = f;
295
+ (out[rule] ??= []).push(rest);
296
+ }
297
+ return out;
298
+ }
299
+ /** `{file, findings:{…}}` pretty-JSON — byte-identical to `lat-check --json`. */
300
+ toJson() {
301
+ return this.#r.json;
302
+ }
303
+ /** One flat `{rule, …}` per line — byte-identical to `lat-check --ndjson`. */
304
+ toNdjson() {
305
+ return this.#r.ndjson;
306
+ }
307
+ toString() {
308
+ const v = this.isValid ? "valid" : `${this.count} finding(s)`;
309
+ return `<Report ${JSON.stringify(this.file)} ${v} dict=${this.dictVersion}>`;
310
+ }
311
+ };
312
+
199
313
  // ts/subset.ts
200
314
  var AgsSubset = class _AgsSubset {
201
315
  #parent;
@@ -204,7 +318,16 @@ var AgsSubset = class _AgsSubset {
204
318
  this.#parent = parent;
205
319
  this.#filters = filters;
206
320
  }
207
- /** Narrow further by another entity's id (e.g. add a SAMP filter). */
321
+ /**
322
+ * Narrow further by another entity's id (e.g. add a SAMP filter on top of a
323
+ * LOCA one). Returns a fresh subset — filters accumulate, they never mutate.
324
+ *
325
+ * @param group - The group code whose `_ID` key to filter on (`"SAMP"` becomes
326
+ * the `SAMP_ID` key column).
327
+ * @param values - The ids to keep; a row survives when its `<group>_ID` is one
328
+ * of these.
329
+ * @returns A new `AgsSubset` carrying this filter plus all the existing ones.
330
+ */
208
331
  at(group, values) {
209
332
  return new _AgsSubset(this.#parent, [...this.#filters, [`${group}_ID`, [...values]]]);
210
333
  }
@@ -226,17 +349,38 @@ var AgsSubset = class _AgsSubset {
226
349
  };
227
350
 
228
351
  // ts/ags4-file.ts
229
- var Ags4File = class {
352
+ var Ags4File = class _Ags4File {
230
353
  #reading;
231
- // One arrow-js Table per group, decoded once on first `table(code)`.
354
+ // One RAW keyed arrow-js Table per group, decoded once (with _id/_parent_id
355
+ // for known groups). The relational `#register` reads these.
232
356
  #tables = /* @__PURE__ */ new Map();
357
+ // The key-stripped frame VIEW per group (a zero-copy projection of the raw
358
+ // Table), memoised so `table(code)` keeps returning the same instance.
359
+ #stripped = /* @__PURE__ */ new Map();
233
360
  // The DuckDB engine — created lazily on first sql()/at()/connection.
234
361
  #engine = null;
235
362
  // Memoised AGS4 re-emit (the `text`/`bytes` getters; the emit is O(size)).
236
363
  #text;
237
364
  #bytes;
238
- constructor(reading) {
365
+ // The retained read source (for faithful chained re-runs) + the last verb
366
+ // outcomes: `#report` from `.validate()`, `#fixReport` on a `.fix()` result.
367
+ #src;
368
+ #report;
369
+ #fixReport;
370
+ // The `.ags.idx` certificate carried from `read(..., { index })`, and the check
371
+ // profile of the last `.validate()` engine run — so a following `.certify()`
372
+ // stamps what was actually checked (#294 Batch E / #14).
373
+ #cert;
374
+ #lastCheckFiles = false;
375
+ #lastForced = false;
376
+ constructor(reading, src) {
239
377
  this.#reading = reading;
378
+ this.#src = src;
379
+ }
380
+ /** @internal — `read(..., { index })` attaches a freshness-checked certificate
381
+ * so a later errors-only `.validate()` can skip the rule engine. */
382
+ _attachCert(cert) {
383
+ this.#cert = cert;
240
384
  }
241
385
  // --- metadata (no Arrow decode) ------------------------------------------
242
386
  /** Group codes in file order. */
@@ -252,12 +396,15 @@ var Ags4File = class {
252
396
  if (m === null) throw new Error(`group ${JSON.stringify(code)} not in file`);
253
397
  return m;
254
398
  }
399
+ /** The HEADING-row codes of `code`, in file order. Throws if `code` isn't in the file. */
255
400
  headings(code) {
256
401
  return this.#meta(code).headings;
257
402
  }
403
+ /** The UNIT row of `code`, one per heading. Throws if `code` isn't in the file. */
258
404
  units(code) {
259
405
  return this.#meta(code).units;
260
406
  }
407
+ /** The TYPE row of `code` (the AGS data types), one per heading. Throws if `code` isn't in the file. */
261
408
  types(code) {
262
409
  return this.#meta(code).types;
263
410
  }
@@ -269,15 +416,15 @@ var Ags4File = class {
269
416
  lineNumbers(code) {
270
417
  return this.#meta(code).lineNumbers;
271
418
  }
419
+ /** Whether `code` is one of the file's groups — the cheap membership check `headings`/`table` would otherwise throw on. */
272
420
  has(code) {
273
421
  return this.#reading.meta(code) !== null;
274
422
  }
275
423
  // --- born-typed data (Arrow-direct) --------------------------------------
276
- /** One group as a born-typed arrow-js `Table` (a 2DP heading is Float64, an ID
277
- * Utf8, a DT a Timestamp) the SAME typing the Python/wasm hosts produce,
278
- * byte-identical by construction (one shared `build_record_batch`). Cached per
279
- * group. Throws if `code` isn't in the file. */
280
- table(code) {
424
+ /** The raw keyed Table straight from IPC a KNOWN group carries the two
425
+ * content-addressed key columns `_id`/`_parent_id` first (#303). Cached; both
426
+ * the frame accessor and the relational `#register` read it. */
427
+ #rawTable(code) {
281
428
  const cached = this.#tables.get(code);
282
429
  if (cached !== void 0) return cached;
283
430
  const ipc = this.#reading.tableIpc(code);
@@ -286,6 +433,24 @@ var Ags4File = class {
286
433
  this.#tables.set(code, table);
287
434
  return table;
288
435
  }
436
+ /** One group as a born-typed arrow-js `Table` (a 2DP heading is Float64, an ID
437
+ * Utf8, a DT a Timestamp) — the SAME typing the Python/wasm hosts produce,
438
+ * byte-identical by construction (one shared `build_record_batch`). Cached per
439
+ * group. Throws if `code` isn't in the file.
440
+ *
441
+ * By default the synthetic `_id`/`_parent_id` key columns are **dropped**; pass
442
+ * `{ keys: true }` to include them (the relational `sql()`/`at()` layer always
443
+ * carries them regardless — that's what makes cross-group joins work). */
444
+ table(code, opts) {
445
+ const raw = this.#rawTable(code);
446
+ if (opts?.keys) return raw;
447
+ let stripped = this.#stripped.get(code);
448
+ if (stripped === void 0) {
449
+ stripped = stripSynthKeys(raw);
450
+ this.#stripped.set(code, stripped);
451
+ }
452
+ return stripped;
453
+ }
289
454
  // --- emit / save ---------------------------------------------------------
290
455
  /** Spec-correct AGS4 as text — byte-faithful to the source DATA values
291
456
  * (re-emitted native-side from the retained parse). Memoised. */
@@ -296,19 +461,165 @@ var Ags4File = class {
296
461
  get bytes() {
297
462
  return this.#bytes ??= Buffer.from(this.text, "utf8");
298
463
  }
299
- /** Write the AGS4 to `path` (UTF-8); returns `path`. The inverse of `read`. */
464
+ /** Write the AGS4 to `path` (UTF-8) the inverse of `read`. The bytes are
465
+ * byte-faithful to the source DATA values, re-emitted from the retained parse.
466
+ *
467
+ * @param path Filesystem path to write the UTF-8 AGS4 to.
468
+ * @returns The same `path`, for chaining. */
300
469
  save(path) {
301
470
  writeFileSync(path, this.bytes);
302
471
  return path;
303
472
  }
473
+ // --- fluent verbs (validate / fix / diff) --------------------------------
474
+ /** Resolve this handle to the `(source, opts)` the free fns take: the retained
475
+ * read source (so line numbers match the original), or the re-emit for a
476
+ * synthesised handle. `text` is not re-encoded; a path/bytes source carries its
477
+ * read `encoding`. */
478
+ #freeSource() {
479
+ const s = this.#src;
480
+ if (s?.path !== void 0) return [s.path, { encoding: s.encoding }];
481
+ if (s?.text !== void 0) return [void 0, { text: s.text }];
482
+ if (s?.data !== void 0) return [s.data, { encoding: s.encoding }];
483
+ return [this.bytes, {}];
484
+ }
485
+ /** The last `.validate()` outcome (`undefined` until validated). */
486
+ get report() {
487
+ return this.#report;
488
+ }
489
+ /** The `FixResult` on a handle returned by `.fix()` (`undefined` otherwise). */
490
+ get fixReport() {
491
+ return this.#fixReport;
492
+ }
493
+ /** Validate this file against the AGS4 rules and return `this` (chainable —
494
+ * `read(p).validate().sql(...)`); the outcome lands on `.report`. Same engine as
495
+ * the free `validate()`, run on the source this handle was read from so line
496
+ * numbers match the original. Errors + WARNINGs by default (`warnings`); `fyi`
497
+ * adds the low-signal tier. `encoding` defaults to the one this handle was read
498
+ * with. Mirrors `laterite.Ags4File.validate()`. */
499
+ validate(opts = {}) {
500
+ const warnings = opts.warnings ?? true;
501
+ const fyi = opts.fyi ?? false;
502
+ const checkFiles = opts.checkFiles ?? false;
503
+ if (this.#cert !== void 0 && !warnings && !fyi && this.#cert.matchesNativeValidator() && this.#cert.profileCovers(checkFiles, opts.dictVersion)) {
504
+ this.#report = Report.fromCert(this.#cert, this.#srcLabel());
505
+ return this;
506
+ }
507
+ this.#lastCheckFiles = checkFiles;
508
+ this.#lastForced = opts.dictVersion !== void 0;
509
+ const [source, base] = this.#freeSource();
510
+ this.#report = validate(source, {
511
+ ...base,
512
+ ...opts,
513
+ encoding: opts.encoding ?? base.encoding
514
+ });
515
+ return this;
516
+ }
517
+ /** Mechanically repair this file and return a NEW, repaired `Ags4File` — the
518
+ * fluent transform, so `read(p).fix().validate().save(out)` reads as one chain.
519
+ * The `FixResult` (what was applied + residual findings) rides on the returned
520
+ * handle's `.fixReport`. Safe fixes always apply; `risky` adds the intent-
521
+ * guessing set. `encoding` defaults to this handle's read encoding. Non-
522
+ * destructive — the source on disk is untouched. Mirrors
523
+ * `laterite.Ags4File.fix()`. */
524
+ fix(opts = {}) {
525
+ const [source, base] = this.#freeSource();
526
+ const result = fix(source, { ...base, ...opts, encoding: opts.encoding ?? base.encoding });
527
+ const repaired = new _Ags4File(parseArrow(void 0, void 0, result.bytes, void 0), {
528
+ data: result.bytes
529
+ });
530
+ repaired.#fixReport = result;
531
+ return repaired;
532
+ }
533
+ /** Compare this file (the baseline) against `other` (the revision) — the
534
+ * `RevisionDelta` the free `diff()` returns. `other` is a path, bytes, or another
535
+ * `Ags4File`. `encoding` defaults to this handle's read encoding. Mirrors
536
+ * `laterite.Ags4File.diff()`. */
537
+ diff(other, opts = {}) {
538
+ return diff2(this, other, { ...opts, encoding: opts.encoding ?? this.#src?.encoding });
539
+ }
540
+ // --- certificate (.ags.idx) ----------------------------------------------
541
+ /** The ORIGINAL source bytes — what a certificate indexes + fingerprints, NOT
542
+ * the spec-correct re-emit (which can differ from a non-canonically-formatted
543
+ * on-disk file). A path is re-read, raw bytes returned as-is, text UTF-8-encoded;
544
+ * a synthesised handle falls back to the re-emit. Mirrors `_source_bytes`. */
545
+ #sourceBytes() {
546
+ const s = this.#src;
547
+ if (s?.path !== void 0) return readFileSync(s.path);
548
+ if (s?.data !== void 0) return s.data;
549
+ if (s?.text !== void 0) return Buffer.from(s.text, "utf8");
550
+ return this.bytes;
551
+ }
552
+ /** The `Report.fromCert` label for a certified verdict: the path, or a bytes/
553
+ * text sentinel. */
554
+ #srcLabel() {
555
+ const s = this.#src;
556
+ if (s?.path !== void 0) return s.path;
557
+ if (s?.data !== void 0) return "<bytes>";
558
+ return "<text>";
559
+ }
560
+ /** Mint this file's `.ags.idx` validity certificate — a clean-validation proof
561
+ * plus a byte-offset index — and write it beside the file. REQUIRES a prior
562
+ * clean `.validate()`: certify *vouches for* a passed validation, it does not run
563
+ * one. `path` is the certificate's OUTPUT location (default `<source>.idx`), not
564
+ * a file to certify — it refuses to overwrite the source or any existing
565
+ * non-certificate file. Returns the written path. The cert is byte-identical to
566
+ * Python's / `lat-check --emit-index`'s, so `lat-check` reads it, and a later
567
+ * `read(f, { index })` consumes it to skip re-validation. Mirrors
568
+ * `laterite.Ags4File.certify()`. */
569
+ certify(path) {
570
+ if (this.#report === void 0) {
571
+ throw new Ags4Error(
572
+ "call .validate() before .certify() \u2014 certify records a passed validation, it does not run one"
573
+ );
574
+ }
575
+ if (!this.#report.isValid) {
576
+ throw new Ags4Error(
577
+ `cannot certify a file with ${this.#report.count} finding(s); fix them and re-validate clean first`
578
+ );
579
+ }
580
+ const srcPath = this.#src?.path;
581
+ const out = path ?? (srcPath !== void 0 ? `${srcPath}.idx` : void 0);
582
+ if (out === void 0) {
583
+ throw new Ags4Error(
584
+ "no source path to derive the .ags.idx location from; pass certify(path) for a handle read from text/bytes"
585
+ );
586
+ }
587
+ if (srcPath !== void 0 && resolve(out) === resolve(srcPath)) {
588
+ throw new Ags4Error(
589
+ `certify(path) is the .ags.idx OUTPUT location, not the file to certify \u2014 refusing to overwrite the source ${out}`
590
+ );
591
+ }
592
+ if (existsSync(out) && statSync(out).size > 0) {
593
+ const head = readFileSync(out).subarray(0, 64).toString("utf8").trimStart();
594
+ if (!head.startsWith("{")) {
595
+ throw new Ags4Error(
596
+ `refusing to overwrite ${out}: it is not a laterite certificate (certify writes or replaces an .ags.idx)`
597
+ );
598
+ }
599
+ }
600
+ const cert = Sidecar.assemble(
601
+ this.#sourceBytes(),
602
+ this.#report.dictVersion,
603
+ (/* @__PURE__ */ new Date()).toISOString(),
604
+ void 0,
605
+ void 0,
606
+ void 0,
607
+ this.#lastCheckFiles,
608
+ this.#lastForced
609
+ );
610
+ writeFileSync(out, cert.toJson());
611
+ return out;
612
+ }
304
613
  // --- optional DuckDB engine (sql / at / connection) ----------------------
305
614
  async #getEngine() {
306
615
  if (this.#engine === null) this.#engine = await DuckEngine.create();
307
616
  return this.#engine;
308
617
  }
309
- /** Load one group into the engine on demand (CTAS from its born-typed Table). */
618
+ /** Load one group into the engine on demand (CTAS from its born-typed Table).
619
+ * Uses the RAW keyed Table so the relational layer carries `_id`/`_parent_id`
620
+ * (the accessor strips them; the engine keeps them for joins). */
310
621
  async #register(code, engine) {
311
- await engine.register(code, this.#meta(code), this.table(code));
622
+ await engine.register(code, this.#meta(code), this.#rawTable(code));
312
623
  }
313
624
  async #registerAll(engine) {
314
625
  for (const code of this.groups) await this.#register(code, engine);
@@ -322,7 +633,11 @@ var Ags4File = class {
322
633
  * returns a view whose `table(code)` yields only the rows whose `{group}_ID`
323
634
  * is in `values`. Chain to narrow (`.at("SAMP", […])`); `sub.groups` is the
324
635
  * related groups, `sub.frames()` pulls them all. Groups carrying none of the
325
- * keys pass through. For any other predicate, use `sql("… WHERE …")`. */
636
+ * keys pass through. For any other predicate, use `sql("… WHERE …")`.
637
+ *
638
+ * @param group The parent group whose `{group}_ID` key drives the filter.
639
+ * @param values The id values to keep (empty matches nothing).
640
+ * @returns An `AgsSubset` view; further `.at()` calls accumulate filters. */
326
641
  at(group, values) {
327
642
  return new AgsSubset(this, [[`${group}_ID`, [...values]]]);
328
643
  }
@@ -353,7 +668,9 @@ var Ags4File = class {
353
668
  }
354
669
  }
355
670
  const where = clauses.length > 0 ? clauses.join(" AND ") : "TRUE";
356
- const sql = `SELECT * FROM ${quoteId(code)} WHERE ${where}`;
671
+ const keyed = this.#rawTable(code).schema.fields.some((f) => f.name === "_id");
672
+ const select = keyed ? "* EXCLUDE (_id, _parent_id)" : "*";
673
+ const sql = `SELECT ${select} FROM ${quoteId(code)} WHERE ${where}`;
357
674
  return opts.arrow ? engine.queryArrow(sql, params) : engine.query(sql, params);
358
675
  }
359
676
  // --- lifecycle -----------------------------------------------------------
@@ -366,9 +683,11 @@ var Ags4File = class {
366
683
  this.#engine = null;
367
684
  }
368
685
  }
686
+ /** `using f = read(…)` disposal hook — delegates to `close()`. */
369
687
  [Symbol.dispose]() {
370
688
  this.close();
371
689
  }
690
+ /** A compact one-line summary — group count and `TRAN_AGS` — for logs and the REPL. */
372
691
  toString() {
373
692
  return `<Ags4File groups=${this.groups.length} tranAgs=${JSON.stringify(this.tranAgs)}>`;
374
693
  }
@@ -377,13 +696,15 @@ var Ags4File = class {
377
696
  // ts/build-result.ts
378
697
  import { writeFileSync as writeFileSync2 } from "fs";
379
698
  var BuildResult = class {
380
- constructor(bytes, findings, fixesApplied) {
699
+ constructor(bytes, findings, applied, fixesApplied) {
381
700
  this.bytes = bytes;
382
701
  this.findings = findings;
702
+ this.applied = applied;
383
703
  this.fixesApplied = fixesApplied;
384
704
  }
385
705
  bytes;
386
706
  findings;
707
+ applied;
387
708
  fixesApplied;
388
709
  /** The AGS4 document decoded as text. */
389
710
  get text() {
@@ -430,24 +751,6 @@ var FixResult = class {
430
751
  }
431
752
  };
432
753
 
433
- // ts/native.ts
434
- import {
435
- version,
436
- parseArrow,
437
- runCheck,
438
- fixFile,
439
- listRules,
440
- emitAgs4FromIpc,
441
- Reading,
442
- canonicalType,
443
- displayHint,
444
- parseValue,
445
- transportPack,
446
- transportUnpack,
447
- transportLock,
448
- transportUnlock
449
- } from "#native";
450
-
451
754
  // ts/registry.ts
452
755
  var registry_exports = {};
453
756
  __export(registry_exports, {
@@ -455,6 +758,7 @@ __export(registry_exports, {
455
758
  GroupDescriptor: () => GroupDescriptor,
456
759
  ancestorChain: () => ancestorChain,
457
760
  childGroups: () => childGroups,
761
+ dictionary: () => dictionary,
458
762
  get: () => get,
459
763
  inheritedKeyNames: () => inheritedKeyNames,
460
764
  isKeyStatus: () => isKeyStatus
@@ -26226,9 +26530,13 @@ var GroupDescriptor = class {
26226
26530
  get view() {
26227
26531
  return `v_${this.code.toLowerCase()}`;
26228
26532
  }
26533
+ /** This group's KEY headings (status part-matched via {@link isKeyStatus}), in
26534
+ * declaration order. */
26229
26535
  get keyHeadings() {
26230
26536
  return this.headings.filter((h) => isKeyStatus(h.status));
26231
26537
  }
26538
+ /** This group's non-KEY headings — the complement of {@link keyHeadings} — in
26539
+ * declaration order. */
26232
26540
  get nonKeyHeadings() {
26233
26541
  return this.headings.filter((h) => !isKeyStatus(h.status));
26234
26542
  }
@@ -26239,6 +26547,9 @@ var GROUPS = Object.freeze(
26239
26547
  function get(code) {
26240
26548
  return GROUPS[code];
26241
26549
  }
26550
+ function dictionary(edition) {
26551
+ return JSON.parse(registryDictionaryJson(edition));
26552
+ }
26242
26553
  function childGroups(parentCode) {
26243
26554
  return Object.values(GROUPS).filter((g) => g.parent === parentCode).sort((a, b) => a.code.localeCompare(b.code));
26244
26555
  }
@@ -26255,67 +26566,21 @@ function ancestorChain(code) {
26255
26566
  return chain;
26256
26567
  }
26257
26568
  function inheritedKeyNames(code) {
26569
+ const g = GROUPS[code];
26570
+ if (g === void 0) {
26571
+ throw new Ags4Error(`unknown group code: ${JSON.stringify(code)}`);
26572
+ }
26258
26573
  const names = /* @__PURE__ */ new Set();
26259
- for (const ancestor of ancestorChain(code).slice(1)) {
26260
- for (const h of GROUPS[ancestor].keyHeadings) names.add(h.name);
26574
+ if (g.parent === null) return names;
26575
+ const parent = GROUPS[g.parent];
26576
+ if (parent === void 0) return names;
26577
+ const parentKeys = new Set(parent.keyHeadings.map((h) => h.name));
26578
+ for (const h of g.keyHeadings) {
26579
+ if (parentKeys.has(h.name)) names.add(h.name);
26261
26580
  }
26262
26581
  return names;
26263
26582
  }
26264
26583
 
26265
- // ts/report.ts
26266
- var Report = class {
26267
- #r;
26268
- constructor(r) {
26269
- this.#r = r;
26270
- }
26271
- get file() {
26272
- return this.#r.file;
26273
- }
26274
- get dictVersion() {
26275
- return this.#r.dictVersion;
26276
- }
26277
- get resolution() {
26278
- return this.#r.resolution;
26279
- }
26280
- get count() {
26281
- return this.#r.count;
26282
- }
26283
- /** `true` iff there are zero findings (distinct from the native `ok`, which
26284
- * only means "validatable"). */
26285
- get isValid() {
26286
- return this.#r.count === 0;
26287
- }
26288
- get exitCode() {
26289
- return this.#r.exitCode;
26290
- }
26291
- /** All findings, in `lat-check` order: `{rule, line?, group, desc, severity?}`. */
26292
- get findings() {
26293
- return this.#r.findings;
26294
- }
26295
- /** `{ "AGS Format Rule N": [{line?, group, desc, …}] }` — the spec-rule
26296
- * grouping (mirrors `Report.by_rule`). */
26297
- byRule() {
26298
- const out = {};
26299
- for (const f of this.#r.findings) {
26300
- const { rule, ...rest } = f;
26301
- (out[rule] ??= []).push(rest);
26302
- }
26303
- return out;
26304
- }
26305
- /** `{file, findings:{…}}` pretty-JSON — byte-identical to `lat-check --json`. */
26306
- toJson() {
26307
- return this.#r.json;
26308
- }
26309
- /** One flat `{rule, …}` per line — byte-identical to `lat-check --ndjson`. */
26310
- toNdjson() {
26311
- return this.#r.ndjson;
26312
- }
26313
- toString() {
26314
- const v = this.isValid ? "valid" : `${this.count} finding(s)`;
26315
- return `<Report ${JSON.stringify(this.file)} ${v} dict=${this.dictVersion}>`;
26316
- }
26317
- };
26318
-
26319
26584
  // ts/ags-group.ts
26320
26585
  var AgsGroup = class {
26321
26586
  };
@@ -31249,11 +31514,28 @@ function unlock(src, dest, password) {
31249
31514
  function read(source, opts = {}) {
31250
31515
  const path = typeof source === "string" ? source : void 0;
31251
31516
  const data = typeof source === "string" || source == null ? void 0 : source;
31517
+ let handle;
31252
31518
  try {
31253
- return new Ags4File(parseArrow(path, opts.text, data, opts.encoding));
31519
+ handle = new Ags4File(parseArrow(path, opts.text, data, opts.encoding), {
31520
+ path,
31521
+ text: opts.text,
31522
+ data,
31523
+ encoding: opts.encoding
31524
+ });
31254
31525
  } catch (e) {
31255
31526
  throw fromNativeError(e);
31256
31527
  }
31528
+ if (opts.index !== void 0) {
31529
+ const cert = Sidecar.fromJson(readFileSync2(opts.index));
31530
+ const srcBytes = data ?? (path !== void 0 ? readFileSync2(path) : Buffer.from(opts.text ?? "", "utf8"));
31531
+ if (!cert.isFreshFor(srcBytes)) {
31532
+ throw new StaleCertError(
31533
+ `certificate ${opts.index} is stale for this file \u2014 its size / SHA-256 differ; rebuild it with read(...).validate().certify()`
31534
+ );
31535
+ }
31536
+ handle._attachCert(cert);
31537
+ }
31538
+ return handle;
31257
31539
  }
31258
31540
  function validate(source, opts = {}) {
31259
31541
  const path = typeof source === "string" ? source : void 0;
@@ -31303,20 +31585,50 @@ function walkTree(root) {
31303
31585
  }
31304
31586
  };
31305
31587
  visit(root);
31588
+ for (const [code, rows] of buckets) {
31589
+ const desc = get(code);
31590
+ for (const h of desc.headings) {
31591
+ if (isKeyStatus(h.status)) continue;
31592
+ if (rows.every((r) => r[h.name] == null)) {
31593
+ for (const r of rows) delete r[h.name];
31594
+ }
31595
+ }
31596
+ }
31306
31597
  return [...buckets];
31307
31598
  }
31599
+ function checkMeta(meta, name, columns) {
31600
+ if (meta === void 0) return;
31601
+ for (const [code, hmap] of Object.entries(meta)) {
31602
+ const known = columns.get(code);
31603
+ if (known === void 0) {
31604
+ throw new Ags4Error(`buildAgs4 ${name}: unknown group ${JSON.stringify(code)}`);
31605
+ }
31606
+ for (const heading of Object.keys(hmap)) {
31607
+ if (!known.has(heading)) {
31608
+ throw new Ags4Error(
31609
+ `buildAgs4 ${name}: group ${JSON.stringify(code)} has no heading ${JSON.stringify(heading)}`
31610
+ );
31611
+ }
31612
+ }
31613
+ }
31614
+ }
31308
31615
  function buildAgs4(groups, opts = {}) {
31309
31616
  const items = groups instanceof AgsGroup ? walkTree(groups) : groups instanceof Map ? [...groups] : groups;
31310
- const ipcGroups = items.map(([code, data]) => {
31311
- const table = Array.isArray(data) ? rowsToTable(data) : data;
31312
- return { code, ipc: Buffer.from(tableToIPC(table, "stream")) };
31313
- });
31314
- const res = emitAgs4FromIpc(ipcGroups, opts.dictVersion, opts.mode);
31617
+ const ipcGroups = [];
31618
+ const columns = /* @__PURE__ */ new Map();
31619
+ for (const [code, data] of items) {
31620
+ const table = stripSynthKeys(Array.isArray(data) ? rowsToTable(data) : data);
31621
+ columns.set(code, new Set(table.schema.fields.map((f) => f.name)));
31622
+ ipcGroups.push({ code, ipc: Buffer.from(tableToIPC(table, "stream")) });
31623
+ }
31624
+ checkMeta(opts.units, "units", columns);
31625
+ checkMeta(opts.types, "types", columns);
31626
+ const res = emitAgs4FromIpc(ipcGroups, opts.dictVersion, opts.mode, opts.units, opts.types);
31315
31627
  const byRule = JSON.parse(res.findingsJson);
31316
31628
  const findings = Object.entries(byRule).flatMap(
31317
31629
  ([rule, list]) => list.map((f) => ({ rule, ...f }))
31318
31630
  );
31319
- return new BuildResult(res.bytes, findings, res.fixesApplied);
31631
+ return new BuildResult(res.bytes, findings, res.applied, res.fixesApplied);
31320
31632
  }
31321
31633
  function listRules2() {
31322
31634
  return JSON.parse(listRules()).rules;
@@ -31328,6 +31640,33 @@ function fix(source, opts = {}) {
31328
31640
  if (!r.ok) throw makeError(r.errorKind ?? "", r.exitCode, r.error ?? "unknown error");
31329
31641
  return new FixResult(r.fixed, r.residual, r.applied, r.dictVersion);
31330
31642
  }
31643
+ function diffBytes(x) {
31644
+ if (x instanceof Ags4File) return x.bytes;
31645
+ if (typeof x !== "string") return x;
31646
+ try {
31647
+ return readFileSync2(x);
31648
+ } catch (e) {
31649
+ if (e && typeof e === "object" && e.code === "ENOENT") {
31650
+ throw new FileNotFoundError(`No such file or directory: ${x}`, 3);
31651
+ }
31652
+ throw e;
31653
+ }
31654
+ }
31655
+ function diff2(a, b, opts = {}) {
31656
+ const aBytes = diffBytes(a);
31657
+ const bBytes = diffBytes(b);
31658
+ try {
31659
+ return JSON.parse(diff(aBytes, bBytes, opts.dictVersion, opts.encoding));
31660
+ } catch (e) {
31661
+ throw fromNativeError(e);
31662
+ }
31663
+ }
31664
+ function toExcel(agsPath, xlsxPath, opts = {}) {
31665
+ return ags4ToExcel(agsPath, xlsxPath, opts.groups);
31666
+ }
31667
+ function fromExcel(xlsxPath, agsPath, opts = {}) {
31668
+ return excelToAgs4(xlsxPath, agsPath, opts.formatNumericColumns);
31669
+ }
31331
31670
  export {
31332
31671
  AAVT,
31333
31672
  ABBR,
@@ -31498,6 +31837,7 @@ export {
31498
31837
  SHBT,
31499
31838
  STND,
31500
31839
  SUCT,
31840
+ StaleCertError,
31501
31841
  TNPC,
31502
31842
  TRAN,
31503
31843
  TREG,
@@ -31517,10 +31857,13 @@ export {
31517
31857
  WSTG,
31518
31858
  ags_types_exports as agsTypes,
31519
31859
  buildAgs4,
31860
+ diff2 as diff,
31520
31861
  fix,
31862
+ fromExcel,
31521
31863
  listRules2 as listRules,
31522
31864
  read,
31523
31865
  registry_exports as registry,
31866
+ toExcel,
31524
31867
  transport_exports as transport,
31525
31868
  validate,
31526
31869
  version