laterite 0.5.1 → 0.6.1

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.cjs CHANGED
@@ -189,6 +189,7 @@ __export(index_exports, {
189
189
  SHBT: () => SHBT,
190
190
  STND: () => STND,
191
191
  SUCT: () => SUCT,
192
+ StaleCertError: () => StaleCertError,
192
193
  TNPC: () => TNPC,
193
194
  TRAN: () => TRAN,
194
195
  TREG: () => TREG,
@@ -208,19 +209,24 @@ __export(index_exports, {
208
209
  WSTG: () => WSTG,
209
210
  agsTypes: () => ags_types_exports,
210
211
  buildAgs4: () => buildAgs4,
212
+ diff: () => diff2,
211
213
  fix: () => fix,
214
+ fromExcel: () => fromExcel,
212
215
  listRules: () => listRules2,
213
216
  read: () => read,
214
217
  registry: () => registry_exports,
218
+ toExcel: () => toExcel,
215
219
  transport: () => transport_exports,
216
220
  validate: () => validate,
217
221
  version: () => import_native.version
218
222
  });
219
223
  module.exports = __toCommonJS(index_exports);
224
+ var import_node_fs4 = require("fs");
220
225
  var import_apache_arrow3 = require("apache-arrow");
221
226
 
222
227
  // ts/ags4-file.ts
223
228
  var import_node_fs = require("fs");
229
+ var import_node_path = require("path");
224
230
  var import_apache_arrow2 = require("apache-arrow");
225
231
 
226
232
  // ts/duckdb.ts
@@ -260,6 +266,12 @@ var BadDictError = class extends Ags4Error {
260
266
  this.name = "BadDictError";
261
267
  }
262
268
  };
269
+ var StaleCertError = class extends Ags4Error {
270
+ constructor(message, exitCode = 4) {
271
+ super(message, exitCode);
272
+ this.name = "StaleCertError";
273
+ }
274
+ };
263
275
  function makeError(kind, exitCode, message) {
264
276
  switch (kind) {
265
277
  case "not_found":
@@ -295,6 +307,10 @@ function fromNativeError(e) {
295
307
  function quoteId(name) {
296
308
  return `"${name.replace(/"/g, '""')}"`;
297
309
  }
310
+ function stripSynthKeys(table) {
311
+ const dataCols = table.schema.fields.filter((f) => !f.name.startsWith("_")).map((f) => f.name);
312
+ return dataCols.length === table.numCols ? table : table.select(dataCols);
313
+ }
298
314
  var DUCK_PACKAGE = "@duckdb/node-api";
299
315
  var duckMod = null;
300
316
  function loadDuck() {
@@ -334,11 +350,19 @@ var DuckEngine = class _DuckEngine {
334
350
  * rows appended from the born-typed arrow-js Table). Idempotent per code. */
335
351
  async register(code, meta, table) {
336
352
  if (this.registered.has(code)) return;
337
- const cols = meta.headings.map((h, i) => `${quoteId(h)} ${meta.sqlTypes[i]}`).join(", ");
353
+ const keyCols = table.schema.fields.some((f) => f.name === "_id") ? ["_id", "_parent_id"] : [];
354
+ const cols = [
355
+ ...keyCols.map((k) => `${quoteId(k)} VARCHAR`),
356
+ ...meta.headings.map((h, i) => `${quoteId(h)} ${meta.sqlTypes[i]}`)
357
+ ].join(", ");
338
358
  await this.#con.run(`CREATE TABLE ${quoteId(code)} (${cols})`);
339
359
  const appender = await this.#con.createAppender(code);
360
+ const keyVectors = keyCols.map((k) => table.getChild(k));
340
361
  const vectors = meta.headings.map((h) => table.getChild(h));
341
362
  for (let r = 0; r < table.numRows; r++) {
363
+ for (let c = 0; c < keyCols.length; c++) {
364
+ this.#appendCell(appender, "VARCHAR", keyVectors[c]?.get(r));
365
+ }
342
366
  for (let c = 0; c < meta.headings.length; c++) {
343
367
  this.#appendCell(appender, meta.sqlTypes[c] ?? "VARCHAR", vectors[c]?.get(r));
344
368
  }
@@ -408,6 +432,80 @@ function toMicros(value) {
408
432
  return BigInt(Math.round(ms * 1e3));
409
433
  }
410
434
 
435
+ // ts/native.ts
436
+ var import_native = require("#native");
437
+
438
+ // ts/report.ts
439
+ var Report = class _Report {
440
+ #r;
441
+ constructor(r) {
442
+ this.#r = r;
443
+ }
444
+ /** Synthesise a clean report from a fresh certificate — the engine-skipped
445
+ * outcome of `Ags4File.validate()` on an `index=`-certified file. `resolution`
446
+ * is the sentinel `"certified"` (the engine never emits it), `count` is 0, and
447
+ * the edition is the cert's. Mirrors laterite-py's `Report.from_cert`. */
448
+ static fromCert(cert, label) {
449
+ return new _Report({
450
+ ok: true,
451
+ exitCode: 0,
452
+ file: label,
453
+ dictVersion: cert.edition,
454
+ resolution: "certified",
455
+ count: 0,
456
+ findings: [],
457
+ json: JSON.stringify({ file: label, findings: {} }),
458
+ ndjson: ""
459
+ });
460
+ }
461
+ get file() {
462
+ return this.#r.file;
463
+ }
464
+ get dictVersion() {
465
+ return this.#r.dictVersion;
466
+ }
467
+ get resolution() {
468
+ return this.#r.resolution;
469
+ }
470
+ get count() {
471
+ return this.#r.count;
472
+ }
473
+ /** `true` iff there are zero findings (distinct from the native `ok`, which
474
+ * only means "validatable"). */
475
+ get isValid() {
476
+ return this.#r.count === 0;
477
+ }
478
+ get exitCode() {
479
+ return this.#r.exitCode;
480
+ }
481
+ /** All findings, in `lat-check` order: `{rule, line?, group, desc, severity?}`. */
482
+ get findings() {
483
+ return this.#r.findings;
484
+ }
485
+ /** `{ "AGS Format Rule N": [{line?, group, desc, …}] }` — the spec-rule
486
+ * grouping (mirrors `Report.by_rule`). */
487
+ byRule() {
488
+ const out = {};
489
+ for (const f of this.#r.findings) {
490
+ const { rule, ...rest } = f;
491
+ (out[rule] ??= []).push(rest);
492
+ }
493
+ return out;
494
+ }
495
+ /** `{file, findings:{…}}` pretty-JSON — byte-identical to `lat-check --json`. */
496
+ toJson() {
497
+ return this.#r.json;
498
+ }
499
+ /** One flat `{rule, …}` per line — byte-identical to `lat-check --ndjson`. */
500
+ toNdjson() {
501
+ return this.#r.ndjson;
502
+ }
503
+ toString() {
504
+ const v = this.isValid ? "valid" : `${this.count} finding(s)`;
505
+ return `<Report ${JSON.stringify(this.file)} ${v} dict=${this.dictVersion}>`;
506
+ }
507
+ };
508
+
411
509
  // ts/subset.ts
412
510
  var AgsSubset = class _AgsSubset {
413
511
  #parent;
@@ -416,7 +514,16 @@ var AgsSubset = class _AgsSubset {
416
514
  this.#parent = parent;
417
515
  this.#filters = filters;
418
516
  }
419
- /** Narrow further by another entity's id (e.g. add a SAMP filter). */
517
+ /**
518
+ * Narrow further by another entity's id (e.g. add a SAMP filter on top of a
519
+ * LOCA one). Returns a fresh subset — filters accumulate, they never mutate.
520
+ *
521
+ * @param group - The group code whose `_ID` key to filter on (`"SAMP"` becomes
522
+ * the `SAMP_ID` key column).
523
+ * @param values - The ids to keep; a row survives when its `<group>_ID` is one
524
+ * of these.
525
+ * @returns A new `AgsSubset` carrying this filter plus all the existing ones.
526
+ */
420
527
  at(group, values) {
421
528
  return new _AgsSubset(this.#parent, [...this.#filters, [`${group}_ID`, [...values]]]);
422
529
  }
@@ -438,17 +545,38 @@ var AgsSubset = class _AgsSubset {
438
545
  };
439
546
 
440
547
  // ts/ags4-file.ts
441
- var Ags4File = class {
548
+ var Ags4File = class _Ags4File {
442
549
  #reading;
443
- // One arrow-js Table per group, decoded once on first `table(code)`.
550
+ // One RAW keyed arrow-js Table per group, decoded once (with _id/_parent_id
551
+ // for known groups). The relational `#register` reads these.
444
552
  #tables = /* @__PURE__ */ new Map();
553
+ // The key-stripped frame VIEW per group (a zero-copy projection of the raw
554
+ // Table), memoised so `table(code)` keeps returning the same instance.
555
+ #stripped = /* @__PURE__ */ new Map();
445
556
  // The DuckDB engine — created lazily on first sql()/at()/connection.
446
557
  #engine = null;
447
558
  // Memoised AGS4 re-emit (the `text`/`bytes` getters; the emit is O(size)).
448
559
  #text;
449
560
  #bytes;
450
- constructor(reading) {
561
+ // The retained read source (for faithful chained re-runs) + the last verb
562
+ // outcomes: `#report` from `.validate()`, `#fixReport` on a `.fix()` result.
563
+ #src;
564
+ #report;
565
+ #fixReport;
566
+ // The `.ags.idx` certificate carried from `read(..., { index })`, and the check
567
+ // profile of the last `.validate()` engine run — so a following `.certify()`
568
+ // stamps what was actually checked (#294 Batch E / #14).
569
+ #cert;
570
+ #lastCheckFiles = false;
571
+ #lastForced = false;
572
+ constructor(reading, src) {
451
573
  this.#reading = reading;
574
+ this.#src = src;
575
+ }
576
+ /** @internal — `read(..., { index })` attaches a freshness-checked certificate
577
+ * so a later errors-only `.validate()` can skip the rule engine. */
578
+ _attachCert(cert) {
579
+ this.#cert = cert;
452
580
  }
453
581
  // --- metadata (no Arrow decode) ------------------------------------------
454
582
  /** Group codes in file order. */
@@ -464,12 +592,15 @@ var Ags4File = class {
464
592
  if (m === null) throw new Error(`group ${JSON.stringify(code)} not in file`);
465
593
  return m;
466
594
  }
595
+ /** The HEADING-row codes of `code`, in file order. Throws if `code` isn't in the file. */
467
596
  headings(code) {
468
597
  return this.#meta(code).headings;
469
598
  }
599
+ /** The UNIT row of `code`, one per heading. Throws if `code` isn't in the file. */
470
600
  units(code) {
471
601
  return this.#meta(code).units;
472
602
  }
603
+ /** The TYPE row of `code` (the AGS data types), one per heading. Throws if `code` isn't in the file. */
473
604
  types(code) {
474
605
  return this.#meta(code).types;
475
606
  }
@@ -481,15 +612,15 @@ var Ags4File = class {
481
612
  lineNumbers(code) {
482
613
  return this.#meta(code).lineNumbers;
483
614
  }
615
+ /** Whether `code` is one of the file's groups — the cheap membership check `headings`/`table` would otherwise throw on. */
484
616
  has(code) {
485
617
  return this.#reading.meta(code) !== null;
486
618
  }
487
619
  // --- born-typed data (Arrow-direct) --------------------------------------
488
- /** One group as a born-typed arrow-js `Table` (a 2DP heading is Float64, an ID
489
- * Utf8, a DT a Timestamp) the SAME typing the Python/wasm hosts produce,
490
- * byte-identical by construction (one shared `build_record_batch`). Cached per
491
- * group. Throws if `code` isn't in the file. */
492
- table(code) {
620
+ /** The raw keyed Table straight from IPC a KNOWN group carries the two
621
+ * content-addressed key columns `_id`/`_parent_id` first (#303). Cached; both
622
+ * the frame accessor and the relational `#register` read it. */
623
+ #rawTable(code) {
493
624
  const cached = this.#tables.get(code);
494
625
  if (cached !== void 0) return cached;
495
626
  const ipc = this.#reading.tableIpc(code);
@@ -498,6 +629,24 @@ var Ags4File = class {
498
629
  this.#tables.set(code, table);
499
630
  return table;
500
631
  }
632
+ /** One group as a born-typed arrow-js `Table` (a 2DP heading is Float64, an ID
633
+ * Utf8, a DT a Timestamp) — the SAME typing the Python/wasm hosts produce,
634
+ * byte-identical by construction (one shared `build_record_batch`). Cached per
635
+ * group. Throws if `code` isn't in the file.
636
+ *
637
+ * By default the synthetic `_id`/`_parent_id` key columns are **dropped**; pass
638
+ * `{ keys: true }` to include them (the relational `sql()`/`at()` layer always
639
+ * carries them regardless — that's what makes cross-group joins work). */
640
+ table(code, opts) {
641
+ const raw = this.#rawTable(code);
642
+ if (opts?.keys) return raw;
643
+ let stripped = this.#stripped.get(code);
644
+ if (stripped === void 0) {
645
+ stripped = stripSynthKeys(raw);
646
+ this.#stripped.set(code, stripped);
647
+ }
648
+ return stripped;
649
+ }
501
650
  // --- emit / save ---------------------------------------------------------
502
651
  /** Spec-correct AGS4 as text — byte-faithful to the source DATA values
503
652
  * (re-emitted native-side from the retained parse). Memoised. */
@@ -508,19 +657,165 @@ var Ags4File = class {
508
657
  get bytes() {
509
658
  return this.#bytes ??= Buffer.from(this.text, "utf8");
510
659
  }
511
- /** Write the AGS4 to `path` (UTF-8); returns `path`. The inverse of `read`. */
660
+ /** Write the AGS4 to `path` (UTF-8) the inverse of `read`. The bytes are
661
+ * byte-faithful to the source DATA values, re-emitted from the retained parse.
662
+ *
663
+ * @param path Filesystem path to write the UTF-8 AGS4 to.
664
+ * @returns The same `path`, for chaining. */
512
665
  save(path) {
513
666
  (0, import_node_fs.writeFileSync)(path, this.bytes);
514
667
  return path;
515
668
  }
669
+ // --- fluent verbs (validate / fix / diff) --------------------------------
670
+ /** Resolve this handle to the `(source, opts)` the free fns take: the retained
671
+ * read source (so line numbers match the original), or the re-emit for a
672
+ * synthesised handle. `text` is not re-encoded; a path/bytes source carries its
673
+ * read `encoding`. */
674
+ #freeSource() {
675
+ const s = this.#src;
676
+ if (s?.path !== void 0) return [s.path, { encoding: s.encoding }];
677
+ if (s?.text !== void 0) return [void 0, { text: s.text }];
678
+ if (s?.data !== void 0) return [s.data, { encoding: s.encoding }];
679
+ return [this.bytes, {}];
680
+ }
681
+ /** The last `.validate()` outcome (`undefined` until validated). */
682
+ get report() {
683
+ return this.#report;
684
+ }
685
+ /** The `FixResult` on a handle returned by `.fix()` (`undefined` otherwise). */
686
+ get fixReport() {
687
+ return this.#fixReport;
688
+ }
689
+ /** Validate this file against the AGS4 rules and return `this` (chainable —
690
+ * `read(p).validate().sql(...)`); the outcome lands on `.report`. Same engine as
691
+ * the free `validate()`, run on the source this handle was read from so line
692
+ * numbers match the original. Errors + WARNINGs by default (`warnings`); `fyi`
693
+ * adds the low-signal tier. `encoding` defaults to the one this handle was read
694
+ * with. Mirrors `laterite.Ags4File.validate()`. */
695
+ validate(opts = {}) {
696
+ const warnings = opts.warnings ?? true;
697
+ const fyi = opts.fyi ?? false;
698
+ const checkFiles = opts.checkFiles ?? false;
699
+ if (this.#cert !== void 0 && !warnings && !fyi && this.#cert.matchesNativeValidator() && this.#cert.profileCovers(checkFiles, opts.dictVersion)) {
700
+ this.#report = Report.fromCert(this.#cert, this.#srcLabel());
701
+ return this;
702
+ }
703
+ this.#lastCheckFiles = checkFiles;
704
+ this.#lastForced = opts.dictVersion !== void 0;
705
+ const [source, base] = this.#freeSource();
706
+ this.#report = validate(source, {
707
+ ...base,
708
+ ...opts,
709
+ encoding: opts.encoding ?? base.encoding
710
+ });
711
+ return this;
712
+ }
713
+ /** Mechanically repair this file and return a NEW, repaired `Ags4File` — the
714
+ * fluent transform, so `read(p).fix().validate().save(out)` reads as one chain.
715
+ * The `FixResult` (what was applied + residual findings) rides on the returned
716
+ * handle's `.fixReport`. Safe fixes always apply; `risky` adds the intent-
717
+ * guessing set. `encoding` defaults to this handle's read encoding. Non-
718
+ * destructive — the source on disk is untouched. Mirrors
719
+ * `laterite.Ags4File.fix()`. */
720
+ fix(opts = {}) {
721
+ const [source, base] = this.#freeSource();
722
+ const result = fix(source, { ...base, ...opts, encoding: opts.encoding ?? base.encoding });
723
+ const repaired = new _Ags4File((0, import_native.parseArrow)(void 0, void 0, result.bytes, void 0), {
724
+ data: result.bytes
725
+ });
726
+ repaired.#fixReport = result;
727
+ return repaired;
728
+ }
729
+ /** Compare this file (the baseline) against `other` (the revision) — the
730
+ * `RevisionDelta` the free `diff()` returns. `other` is a path, bytes, or another
731
+ * `Ags4File`. `encoding` defaults to this handle's read encoding. Mirrors
732
+ * `laterite.Ags4File.diff()`. */
733
+ diff(other, opts = {}) {
734
+ return diff2(this, other, { ...opts, encoding: opts.encoding ?? this.#src?.encoding });
735
+ }
736
+ // --- certificate (.ags.idx) ----------------------------------------------
737
+ /** The ORIGINAL source bytes — what a certificate indexes + fingerprints, NOT
738
+ * the spec-correct re-emit (which can differ from a non-canonically-formatted
739
+ * on-disk file). A path is re-read, raw bytes returned as-is, text UTF-8-encoded;
740
+ * a synthesised handle falls back to the re-emit. Mirrors `_source_bytes`. */
741
+ #sourceBytes() {
742
+ const s = this.#src;
743
+ if (s?.path !== void 0) return (0, import_node_fs.readFileSync)(s.path);
744
+ if (s?.data !== void 0) return s.data;
745
+ if (s?.text !== void 0) return Buffer.from(s.text, "utf8");
746
+ return this.bytes;
747
+ }
748
+ /** The `Report.fromCert` label for a certified verdict: the path, or a bytes/
749
+ * text sentinel. */
750
+ #srcLabel() {
751
+ const s = this.#src;
752
+ if (s?.path !== void 0) return s.path;
753
+ if (s?.data !== void 0) return "<bytes>";
754
+ return "<text>";
755
+ }
756
+ /** Mint this file's `.ags.idx` validity certificate — a clean-validation proof
757
+ * plus a byte-offset index — and write it beside the file. REQUIRES a prior
758
+ * clean `.validate()`: certify *vouches for* a passed validation, it does not run
759
+ * one. `path` is the certificate's OUTPUT location (default `<source>.idx`), not
760
+ * a file to certify — it refuses to overwrite the source or any existing
761
+ * non-certificate file. Returns the written path. The cert is byte-identical to
762
+ * Python's / `lat-check --emit-index`'s, so `lat-check` reads it, and a later
763
+ * `read(f, { index })` consumes it to skip re-validation. Mirrors
764
+ * `laterite.Ags4File.certify()`. */
765
+ certify(path) {
766
+ if (this.#report === void 0) {
767
+ throw new Ags4Error(
768
+ "call .validate() before .certify() \u2014 certify records a passed validation, it does not run one"
769
+ );
770
+ }
771
+ if (!this.#report.isValid) {
772
+ throw new Ags4Error(
773
+ `cannot certify a file with ${this.#report.count} finding(s); fix them and re-validate clean first`
774
+ );
775
+ }
776
+ const srcPath = this.#src?.path;
777
+ const out = path ?? (srcPath !== void 0 ? `${srcPath}.idx` : void 0);
778
+ if (out === void 0) {
779
+ throw new Ags4Error(
780
+ "no source path to derive the .ags.idx location from; pass certify(path) for a handle read from text/bytes"
781
+ );
782
+ }
783
+ if (srcPath !== void 0 && (0, import_node_path.resolve)(out) === (0, import_node_path.resolve)(srcPath)) {
784
+ throw new Ags4Error(
785
+ `certify(path) is the .ags.idx OUTPUT location, not the file to certify \u2014 refusing to overwrite the source ${out}`
786
+ );
787
+ }
788
+ if ((0, import_node_fs.existsSync)(out) && (0, import_node_fs.statSync)(out).size > 0) {
789
+ const head = (0, import_node_fs.readFileSync)(out).subarray(0, 64).toString("utf8").trimStart();
790
+ if (!head.startsWith("{")) {
791
+ throw new Ags4Error(
792
+ `refusing to overwrite ${out}: it is not a laterite certificate (certify writes or replaces an .ags.idx)`
793
+ );
794
+ }
795
+ }
796
+ const cert = import_native.Sidecar.assemble(
797
+ this.#sourceBytes(),
798
+ this.#report.dictVersion,
799
+ (/* @__PURE__ */ new Date()).toISOString(),
800
+ void 0,
801
+ void 0,
802
+ void 0,
803
+ this.#lastCheckFiles,
804
+ this.#lastForced
805
+ );
806
+ (0, import_node_fs.writeFileSync)(out, cert.toJson());
807
+ return out;
808
+ }
516
809
  // --- optional DuckDB engine (sql / at / connection) ----------------------
517
810
  async #getEngine() {
518
811
  if (this.#engine === null) this.#engine = await DuckEngine.create();
519
812
  return this.#engine;
520
813
  }
521
- /** Load one group into the engine on demand (CTAS from its born-typed Table). */
814
+ /** Load one group into the engine on demand (CTAS from its born-typed Table).
815
+ * Uses the RAW keyed Table so the relational layer carries `_id`/`_parent_id`
816
+ * (the accessor strips them; the engine keeps them for joins). */
522
817
  async #register(code, engine) {
523
- await engine.register(code, this.#meta(code), this.table(code));
818
+ await engine.register(code, this.#meta(code), this.#rawTable(code));
524
819
  }
525
820
  async #registerAll(engine) {
526
821
  for (const code of this.groups) await this.#register(code, engine);
@@ -534,7 +829,11 @@ var Ags4File = class {
534
829
  * returns a view whose `table(code)` yields only the rows whose `{group}_ID`
535
830
  * is in `values`. Chain to narrow (`.at("SAMP", […])`); `sub.groups` is the
536
831
  * related groups, `sub.frames()` pulls them all. Groups carrying none of the
537
- * keys pass through. For any other predicate, use `sql("… WHERE …")`. */
832
+ * keys pass through. For any other predicate, use `sql("… WHERE …")`.
833
+ *
834
+ * @param group The parent group whose `{group}_ID` key drives the filter.
835
+ * @param values The id values to keep (empty matches nothing).
836
+ * @returns An `AgsSubset` view; further `.at()` calls accumulate filters. */
538
837
  at(group, values) {
539
838
  return new AgsSubset(this, [[`${group}_ID`, [...values]]]);
540
839
  }
@@ -565,7 +864,9 @@ var Ags4File = class {
565
864
  }
566
865
  }
567
866
  const where = clauses.length > 0 ? clauses.join(" AND ") : "TRUE";
568
- const sql = `SELECT * FROM ${quoteId(code)} WHERE ${where}`;
867
+ const keyed = this.#rawTable(code).schema.fields.some((f) => f.name === "_id");
868
+ const select = keyed ? "* EXCLUDE (_id, _parent_id)" : "*";
869
+ const sql = `SELECT ${select} FROM ${quoteId(code)} WHERE ${where}`;
569
870
  return opts.arrow ? engine.queryArrow(sql, params) : engine.query(sql, params);
570
871
  }
571
872
  // --- lifecycle -----------------------------------------------------------
@@ -578,9 +879,11 @@ var Ags4File = class {
578
879
  this.#engine = null;
579
880
  }
580
881
  }
882
+ /** `using f = read(…)` disposal hook — delegates to `close()`. */
581
883
  [Symbol.dispose]() {
582
884
  this.close();
583
885
  }
886
+ /** A compact one-line summary — group count and `TRAN_AGS` — for logs and the REPL. */
584
887
  toString() {
585
888
  return `<Ags4File groups=${this.groups.length} tranAgs=${JSON.stringify(this.tranAgs)}>`;
586
889
  }
@@ -589,13 +892,15 @@ var Ags4File = class {
589
892
  // ts/build-result.ts
590
893
  var import_node_fs2 = require("fs");
591
894
  var BuildResult = class {
592
- constructor(bytes, findings, fixesApplied) {
895
+ constructor(bytes, findings, applied, fixesApplied) {
593
896
  this.bytes = bytes;
594
897
  this.findings = findings;
898
+ this.applied = applied;
595
899
  this.fixesApplied = fixesApplied;
596
900
  }
597
901
  bytes;
598
902
  findings;
903
+ applied;
599
904
  fixesApplied;
600
905
  /** The AGS4 document decoded as text. */
601
906
  get text() {
@@ -642,9 +947,6 @@ var FixResult = class {
642
947
  }
643
948
  };
644
949
 
645
- // ts/native.ts
646
- var import_native = require("#native");
647
-
648
950
  // ts/registry.ts
649
951
  var registry_exports = {};
650
952
  __export(registry_exports, {
@@ -652,6 +954,7 @@ __export(registry_exports, {
652
954
  GroupDescriptor: () => GroupDescriptor,
653
955
  ancestorChain: () => ancestorChain,
654
956
  childGroups: () => childGroups,
957
+ dictionary: () => dictionary,
655
958
  get: () => get,
656
959
  inheritedKeyNames: () => inheritedKeyNames,
657
960
  isKeyStatus: () => isKeyStatus
@@ -26423,9 +26726,13 @@ var GroupDescriptor = class {
26423
26726
  get view() {
26424
26727
  return `v_${this.code.toLowerCase()}`;
26425
26728
  }
26729
+ /** This group's KEY headings (status part-matched via {@link isKeyStatus}), in
26730
+ * declaration order. */
26426
26731
  get keyHeadings() {
26427
26732
  return this.headings.filter((h) => isKeyStatus(h.status));
26428
26733
  }
26734
+ /** This group's non-KEY headings — the complement of {@link keyHeadings} — in
26735
+ * declaration order. */
26429
26736
  get nonKeyHeadings() {
26430
26737
  return this.headings.filter((h) => !isKeyStatus(h.status));
26431
26738
  }
@@ -26436,6 +26743,9 @@ var GROUPS = Object.freeze(
26436
26743
  function get(code) {
26437
26744
  return GROUPS[code];
26438
26745
  }
26746
+ function dictionary(edition) {
26747
+ return JSON.parse((0, import_native.registryDictionaryJson)(edition));
26748
+ }
26439
26749
  function childGroups(parentCode) {
26440
26750
  return Object.values(GROUPS).filter((g) => g.parent === parentCode).sort((a, b) => a.code.localeCompare(b.code));
26441
26751
  }
@@ -26452,67 +26762,21 @@ function ancestorChain(code) {
26452
26762
  return chain;
26453
26763
  }
26454
26764
  function inheritedKeyNames(code) {
26765
+ const g = GROUPS[code];
26766
+ if (g === void 0) {
26767
+ throw new Ags4Error(`unknown group code: ${JSON.stringify(code)}`);
26768
+ }
26455
26769
  const names = /* @__PURE__ */ new Set();
26456
- for (const ancestor of ancestorChain(code).slice(1)) {
26457
- for (const h of GROUPS[ancestor].keyHeadings) names.add(h.name);
26770
+ if (g.parent === null) return names;
26771
+ const parent = GROUPS[g.parent];
26772
+ if (parent === void 0) return names;
26773
+ const parentKeys = new Set(parent.keyHeadings.map((h) => h.name));
26774
+ for (const h of g.keyHeadings) {
26775
+ if (parentKeys.has(h.name)) names.add(h.name);
26458
26776
  }
26459
26777
  return names;
26460
26778
  }
26461
26779
 
26462
- // ts/report.ts
26463
- var Report = class {
26464
- #r;
26465
- constructor(r) {
26466
- this.#r = r;
26467
- }
26468
- get file() {
26469
- return this.#r.file;
26470
- }
26471
- get dictVersion() {
26472
- return this.#r.dictVersion;
26473
- }
26474
- get resolution() {
26475
- return this.#r.resolution;
26476
- }
26477
- get count() {
26478
- return this.#r.count;
26479
- }
26480
- /** `true` iff there are zero findings (distinct from the native `ok`, which
26481
- * only means "validatable"). */
26482
- get isValid() {
26483
- return this.#r.count === 0;
26484
- }
26485
- get exitCode() {
26486
- return this.#r.exitCode;
26487
- }
26488
- /** All findings, in `lat-check` order: `{rule, line?, group, desc, severity?}`. */
26489
- get findings() {
26490
- return this.#r.findings;
26491
- }
26492
- /** `{ "AGS Format Rule N": [{line?, group, desc, …}] }` — the spec-rule
26493
- * grouping (mirrors `Report.by_rule`). */
26494
- byRule() {
26495
- const out = {};
26496
- for (const f of this.#r.findings) {
26497
- const { rule, ...rest } = f;
26498
- (out[rule] ??= []).push(rest);
26499
- }
26500
- return out;
26501
- }
26502
- /** `{file, findings:{…}}` pretty-JSON — byte-identical to `lat-check --json`. */
26503
- toJson() {
26504
- return this.#r.json;
26505
- }
26506
- /** One flat `{rule, …}` per line — byte-identical to `lat-check --ndjson`. */
26507
- toNdjson() {
26508
- return this.#r.ndjson;
26509
- }
26510
- toString() {
26511
- const v = this.isValid ? "valid" : `${this.count} finding(s)`;
26512
- return `<Report ${JSON.stringify(this.file)} ${v} dict=${this.dictVersion}>`;
26513
- }
26514
- };
26515
-
26516
26780
  // ts/ags-group.ts
26517
26781
  var AgsGroup = class {
26518
26782
  };
@@ -31446,11 +31710,28 @@ function unlock(src, dest, password) {
31446
31710
  function read(source, opts = {}) {
31447
31711
  const path = typeof source === "string" ? source : void 0;
31448
31712
  const data = typeof source === "string" || source == null ? void 0 : source;
31713
+ let handle;
31449
31714
  try {
31450
- return new Ags4File((0, import_native.parseArrow)(path, opts.text, data, opts.encoding));
31715
+ handle = new Ags4File((0, import_native.parseArrow)(path, opts.text, data, opts.encoding), {
31716
+ path,
31717
+ text: opts.text,
31718
+ data,
31719
+ encoding: opts.encoding
31720
+ });
31451
31721
  } catch (e) {
31452
31722
  throw fromNativeError(e);
31453
31723
  }
31724
+ if (opts.index !== void 0) {
31725
+ const cert = import_native.Sidecar.fromJson((0, import_node_fs4.readFileSync)(opts.index));
31726
+ const srcBytes = data ?? (path !== void 0 ? (0, import_node_fs4.readFileSync)(path) : Buffer.from(opts.text ?? "", "utf8"));
31727
+ if (!cert.isFreshFor(srcBytes)) {
31728
+ throw new StaleCertError(
31729
+ `certificate ${opts.index} is stale for this file \u2014 its size / SHA-256 differ; rebuild it with read(...).validate().certify()`
31730
+ );
31731
+ }
31732
+ handle._attachCert(cert);
31733
+ }
31734
+ return handle;
31454
31735
  }
31455
31736
  function validate(source, opts = {}) {
31456
31737
  const path = typeof source === "string" ? source : void 0;
@@ -31500,20 +31781,50 @@ function walkTree(root) {
31500
31781
  }
31501
31782
  };
31502
31783
  visit(root);
31784
+ for (const [code, rows] of buckets) {
31785
+ const desc = get(code);
31786
+ for (const h of desc.headings) {
31787
+ if (isKeyStatus(h.status)) continue;
31788
+ if (rows.every((r) => r[h.name] == null)) {
31789
+ for (const r of rows) delete r[h.name];
31790
+ }
31791
+ }
31792
+ }
31503
31793
  return [...buckets];
31504
31794
  }
31795
+ function checkMeta(meta, name, columns) {
31796
+ if (meta === void 0) return;
31797
+ for (const [code, hmap] of Object.entries(meta)) {
31798
+ const known = columns.get(code);
31799
+ if (known === void 0) {
31800
+ throw new Ags4Error(`buildAgs4 ${name}: unknown group ${JSON.stringify(code)}`);
31801
+ }
31802
+ for (const heading of Object.keys(hmap)) {
31803
+ if (!known.has(heading)) {
31804
+ throw new Ags4Error(
31805
+ `buildAgs4 ${name}: group ${JSON.stringify(code)} has no heading ${JSON.stringify(heading)}`
31806
+ );
31807
+ }
31808
+ }
31809
+ }
31810
+ }
31505
31811
  function buildAgs4(groups, opts = {}) {
31506
31812
  const items = groups instanceof AgsGroup ? walkTree(groups) : groups instanceof Map ? [...groups] : groups;
31507
- const ipcGroups = items.map(([code, data]) => {
31508
- const table = Array.isArray(data) ? rowsToTable(data) : data;
31509
- return { code, ipc: Buffer.from((0, import_apache_arrow3.tableToIPC)(table, "stream")) };
31510
- });
31511
- const res = (0, import_native.emitAgs4FromIpc)(ipcGroups, opts.dictVersion, opts.mode);
31813
+ const ipcGroups = [];
31814
+ const columns = /* @__PURE__ */ new Map();
31815
+ for (const [code, data] of items) {
31816
+ const table = stripSynthKeys(Array.isArray(data) ? rowsToTable(data) : data);
31817
+ columns.set(code, new Set(table.schema.fields.map((f) => f.name)));
31818
+ ipcGroups.push({ code, ipc: Buffer.from((0, import_apache_arrow3.tableToIPC)(table, "stream")) });
31819
+ }
31820
+ checkMeta(opts.units, "units", columns);
31821
+ checkMeta(opts.types, "types", columns);
31822
+ const res = (0, import_native.emitAgs4FromIpc)(ipcGroups, opts.dictVersion, opts.mode, opts.units, opts.types);
31512
31823
  const byRule = JSON.parse(res.findingsJson);
31513
31824
  const findings = Object.entries(byRule).flatMap(
31514
31825
  ([rule, list]) => list.map((f) => ({ rule, ...f }))
31515
31826
  );
31516
- return new BuildResult(res.bytes, findings, res.fixesApplied);
31827
+ return new BuildResult(res.bytes, findings, res.applied, res.fixesApplied);
31517
31828
  }
31518
31829
  function listRules2() {
31519
31830
  return JSON.parse((0, import_native.listRules)()).rules;
@@ -31525,6 +31836,33 @@ function fix(source, opts = {}) {
31525
31836
  if (!r.ok) throw makeError(r.errorKind ?? "", r.exitCode, r.error ?? "unknown error");
31526
31837
  return new FixResult(r.fixed, r.residual, r.applied, r.dictVersion);
31527
31838
  }
31839
+ function diffBytes(x) {
31840
+ if (x instanceof Ags4File) return x.bytes;
31841
+ if (typeof x !== "string") return x;
31842
+ try {
31843
+ return (0, import_node_fs4.readFileSync)(x);
31844
+ } catch (e) {
31845
+ if (e && typeof e === "object" && e.code === "ENOENT") {
31846
+ throw new FileNotFoundError(`No such file or directory: ${x}`, 3);
31847
+ }
31848
+ throw e;
31849
+ }
31850
+ }
31851
+ function diff2(a, b, opts = {}) {
31852
+ const aBytes = diffBytes(a);
31853
+ const bBytes = diffBytes(b);
31854
+ try {
31855
+ return JSON.parse((0, import_native.diff)(aBytes, bBytes, opts.dictVersion, opts.encoding));
31856
+ } catch (e) {
31857
+ throw fromNativeError(e);
31858
+ }
31859
+ }
31860
+ function toExcel(agsPath, xlsxPath, opts = {}) {
31861
+ return (0, import_native.ags4ToExcel)(agsPath, xlsxPath, opts.groups);
31862
+ }
31863
+ function fromExcel(xlsxPath, agsPath, opts = {}) {
31864
+ return (0, import_native.excelToAgs4)(xlsxPath, agsPath, opts.formatNumericColumns);
31865
+ }
31528
31866
  // Annotate the CommonJS export names for ESM import in node:
31529
31867
  0 && (module.exports = {
31530
31868
  AAVT,
@@ -31696,6 +32034,7 @@ function fix(source, opts = {}) {
31696
32034
  SHBT,
31697
32035
  STND,
31698
32036
  SUCT,
32037
+ StaleCertError,
31699
32038
  TNPC,
31700
32039
  TRAN,
31701
32040
  TREG,
@@ -31715,10 +32054,13 @@ function fix(source, opts = {}) {
31715
32054
  WSTG,
31716
32055
  agsTypes,
31717
32056
  buildAgs4,
32057
+ diff,
31718
32058
  fix,
32059
+ fromExcel,
31719
32060
  listRules,
31720
32061
  read,
31721
32062
  registry,
32063
+ toExcel,
31722
32064
  transport,
31723
32065
  validate,
31724
32066
  version