@adeu/core 1.21.0 → 1.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/diff.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import diff_match_patch from "diff-match-patch";
2
- import { ModifyText } from "./models.js";
2
+ import { DeleteTableRow, InsertTableRow, ModifyText } from "./models.js";
3
+ import type {
4
+ ExtractStructure,
5
+ RowGeometry,
6
+ TableGeometry,
7
+ } from "./ingest.js";
8
+
9
+ export type DiffEdit = ModifyText | InsertTableRow | DeleteTableRow;
3
10
 
4
11
  function _count_standalone_underscores(s: string): number {
5
12
  let count = 0;
@@ -346,6 +353,498 @@ export function generate_edits_from_text(
346
353
 
347
354
  return edits;
348
355
  }
356
+ // ---------------------------------------------------------------------------
357
+ // Structured (part- and table-aware) diff — QA 2026-07-18 C1/C2.
358
+ // ---------------------------------------------------------------------------
359
+
360
+ type Opcode = [
361
+ tag: "equal" | "replace" | "delete" | "insert",
362
+ i1: number,
363
+ i2: number,
364
+ j1: number,
365
+ j2: number,
366
+ ];
367
+
368
+ /**
369
+ * difflib.SequenceMatcher.get_opcodes() equivalent over string arrays,
370
+ * LCS-based. The row-key arrays this runs on are tiny (table row counts), so
371
+ * the O(n*m) table is negligible. Adjacent non-equal steps coalesce into one
372
+ * "replace"/"delete"/"insert" block exactly like difflib's opcodes.
373
+ */
374
+ function _sequence_opcodes(a: string[], b: string[]): Opcode[] {
375
+ const n = a.length;
376
+ const m = b.length;
377
+ // dp[i][j] = LCS length of a[i:] vs b[j:]
378
+ const dp: Int32Array[] = [];
379
+ for (let i = 0; i <= n; i++) dp.push(new Int32Array(m + 1));
380
+ for (let i = n - 1; i >= 0; i--) {
381
+ for (let j = m - 1; j >= 0; j--) {
382
+ dp[i][j] =
383
+ a[i] === b[j]
384
+ ? dp[i + 1][j + 1] + 1
385
+ : Math.max(dp[i + 1][j], dp[i][j + 1]);
386
+ }
387
+ }
388
+
389
+ const ops: Opcode[] = [];
390
+ let i = 0;
391
+ let j = 0;
392
+ let pend_i1 = 0;
393
+ let pend_j1 = 0;
394
+ let pend_del = 0;
395
+ let pend_ins = 0;
396
+
397
+ const flushPending = () => {
398
+ if (pend_del === 0 && pend_ins === 0) return;
399
+ const tag =
400
+ pend_del > 0 && pend_ins > 0
401
+ ? "replace"
402
+ : pend_del > 0
403
+ ? "delete"
404
+ : "insert";
405
+ ops.push([tag, pend_i1, pend_i1 + pend_del, pend_j1, pend_j1 + pend_ins]);
406
+ pend_del = 0;
407
+ pend_ins = 0;
408
+ };
409
+
410
+ while (i < n || j < m) {
411
+ if (i < n && j < m && a[i] === b[j]) {
412
+ flushPending();
413
+ const i1 = i;
414
+ const j1 = j;
415
+ while (i < n && j < m && a[i] === b[j]) {
416
+ i++;
417
+ j++;
418
+ }
419
+ ops.push(["equal", i1, i, j1, j]);
420
+ } else {
421
+ if (pend_del === 0 && pend_ins === 0) {
422
+ pend_i1 = i;
423
+ pend_j1 = j;
424
+ }
425
+ if (j < m && (i === n || dp[i][j + 1] >= dp[i + 1][j])) {
426
+ pend_ins++;
427
+ j++;
428
+ } else {
429
+ pend_del++;
430
+ i++;
431
+ }
432
+ }
433
+ }
434
+ flushPending();
435
+ return ops;
436
+ }
437
+
438
+ const _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
439
+
440
+ /**
441
+ * Removes generated edits whose image-marker multisets differ between
442
+ * target and new text. The engine (validate_edit_strings) rightly refuses
443
+ * such edits — markers are read-only projections — so emitting them would
444
+ * make the diff pipeline reject its own output. An added/removed image is
445
+ * reported as a warning instead of an unappliable edit.
446
+ */
447
+ function _drop_image_marker_hunks(
448
+ edits: ModifyText[],
449
+ warnings: string[],
450
+ ): ModifyText[] {
451
+ const _normalized = (s: string): string =>
452
+ s.replace(_IMAGE_MARKER_RE, "").replace(/\s+/g, " ").trim();
453
+
454
+ const kept: ModifyText[] = [];
455
+ for (const e of edits) {
456
+ const t_imgs = ((e.target_text || "").match(_IMAGE_MARKER_RE) || []).sort();
457
+ const n_imgs = ((e.new_text || "").match(_IMAGE_MARKER_RE) || []).sort();
458
+ if (JSON.stringify(t_imgs) === JSON.stringify(n_imgs)) {
459
+ kept.push(e);
460
+ continue;
461
+ }
462
+ if (_normalized(e.target_text || "") === _normalized(e.new_text || "")) {
463
+ warnings.push(
464
+ "An inline image was added or removed between the documents. Images cannot be " +
465
+ "transferred by text edits — the image-only difference was skipped; apply it " +
466
+ "manually in Word.",
467
+ );
468
+ } else {
469
+ warnings.push(
470
+ "A text change overlapping an added/removed inline image was skipped — images " +
471
+ "cannot be transferred by text edits. Apply that section manually in Word.",
472
+ );
473
+ }
474
+ }
475
+ return kept;
476
+ }
477
+
478
+ /**
479
+ * True when every projected row reads exactly as its cells joined by " | ".
480
+ * Rows wrapped in tracked-row CriticMarkup ({++ … ++} / {-- … --}) do not,
481
+ * and such tables are diffed as plain text rather than as row structures.
482
+ */
483
+ function _rows_are_plain(text: string, table: TableGeometry): boolean {
484
+ for (const row of table.rows) {
485
+ if (text.substring(row.start, row.end) !== row.cells.join(" | ")) {
486
+ return false;
487
+ }
488
+ }
489
+ return true;
490
+ }
491
+
492
+ const _ROW_KEY_SEP = "\x1f";
493
+
494
+ /**
495
+ * Row-level alignment opcodes between two tables, or null when the row sets
496
+ * differ only cell-internally (no rows added/removed) — the caller then
497
+ * keeps fine-grained word-level text edits instead of row operations.
498
+ */
499
+ function _table_row_opcodes(
500
+ rows_o: RowGeometry[],
501
+ rows_m: RowGeometry[],
502
+ ): Opcode[] | null {
503
+ const keys_o = rows_o.map((r) => r.cells.join(_ROW_KEY_SEP));
504
+ const keys_m = rows_m.map((r) => r.cells.join(_ROW_KEY_SEP));
505
+ const opcodes = _sequence_opcodes(keys_o, keys_m);
506
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
507
+ if (
508
+ tag === "insert" ||
509
+ tag === "delete" ||
510
+ (tag === "replace" && i2 - i1 !== j2 - j1)
511
+ ) {
512
+ return opcodes;
513
+ }
514
+ }
515
+ return null;
516
+ }
517
+
518
+ /**
519
+ * Emits structured operations (delete_row / insert_row / per-row modify)
520
+ * that transform table_o's row set into table_m's, following the alignment
521
+ * `opcodes` from _table_row_opcodes (QA 2026-07-18 C2: a generic text edit
522
+ * cannot add or remove rows — it writes fake pipe text into one cell or is
523
+ * rejected by the cell-count validator).
524
+ */
525
+ function _row_ops_for_table(
526
+ table_o: TableGeometry,
527
+ table_m: TableGeometry,
528
+ opcodes: Opcode[],
529
+ warnings: string[],
530
+ ): DiffEdit[] {
531
+ const rows_o = table_o.rows;
532
+ const rows_m = table_m.rows;
533
+ const keys_o = rows_o.map((r) => r.cells.join(_ROW_KEY_SEP));
534
+
535
+ const row_text = (r: RowGeometry): string => r.cells.join(" | ");
536
+
537
+ // Duplicate row texts make text-anchored row operations ambiguous. The
538
+ // engine fails closed with a strict-mode ambiguity error at apply time;
539
+ // tell the user up front so the rejection is not a surprise.
540
+ if (new Set(keys_o).size !== keys_o.length) {
541
+ warnings.push(
542
+ "A table contains rows with identical text; the generated row operations anchor by " +
543
+ "row text and may be rejected as ambiguous at apply time. If that happens, apply the " +
544
+ "row changes with explicit insert_row/delete_row edits.",
545
+ );
546
+ }
547
+
548
+ // Rows removed by the transformation (deletes + surplus rows of shrinking
549
+ // replaces) can never anchor an insert.
550
+ const removed = new Set<number>();
551
+ const replaced_new_text: Record<number, string> = {};
552
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
553
+ if (tag === "delete") {
554
+ for (let k = i1; k < i2; k++) removed.add(k);
555
+ } else if (tag === "replace") {
556
+ const pairs = Math.min(i2 - i1, j2 - j1);
557
+ for (let k = i1 + pairs; k < i2; k++) removed.add(k);
558
+ for (let k = 0; k < pairs; k++) {
559
+ replaced_new_text[i1 + k] = row_text(rows_m[j1 + k]);
560
+ }
561
+ }
562
+ }
563
+
564
+ const surviving: number[] = [];
565
+ for (let k = 0; k < rows_o.length; k++) {
566
+ if (!removed.has(k)) surviving.push(k);
567
+ }
568
+
569
+ const anchor_text = (orig_idx: number): string => {
570
+ // Anchor on the row's FINAL text: modified rows are matched via the
571
+ // engine's clean-view fallback after their own edit applies.
572
+ return replaced_new_text[orig_idx] !== undefined
573
+ ? replaced_new_text[orig_idx]
574
+ : row_text(rows_o[orig_idx]);
575
+ };
576
+
577
+ const insert_ops = (
578
+ new_rows: RowGeometry[],
579
+ at_orig_index: number,
580
+ ): DiffEdit[] => {
581
+ const ops: DiffEdit[] = [];
582
+ const before = surviving.filter((k) => k < at_orig_index);
583
+ const after = surviving.filter((k) => k >= at_orig_index);
584
+ if (before.length > 0) {
585
+ // Insert below the preceding surviving row. Emitting in reverse keeps
586
+ // the final order: below-A(B), then below-A(C) yields A,C,B — so emit
587
+ // C first.
588
+ const anchor_idx = before[before.length - 1];
589
+ const anchor = anchor_text(anchor_idx);
590
+ for (const r of [...new_rows].reverse()) {
591
+ ops.push({
592
+ type: "insert_row",
593
+ target_text: anchor,
594
+ position: "below",
595
+ cells: [...r.cells],
596
+ // Pin to the anchor row's offset: text anchors alone are
597
+ // ambiguous when tables share identical rows. Pins do not
598
+ // survive JSON round-trips (the strict text match applies then,
599
+ // failing closed with the duplicate-row warning above).
600
+ _match_start_index: rows_o[anchor_idx].start,
601
+ });
602
+ }
603
+ } else if (after.length > 0) {
604
+ const anchor_idx = after[0];
605
+ const anchor = anchor_text(anchor_idx);
606
+ for (const r of new_rows) {
607
+ ops.push({
608
+ type: "insert_row",
609
+ target_text: anchor,
610
+ position: "above",
611
+ cells: [...r.cells],
612
+ _match_start_index: rows_o[anchor_idx].start,
613
+ });
614
+ }
615
+ } else {
616
+ warnings.push(
617
+ "A table gained rows but no original row survives to anchor them; " +
618
+ "these row insertions were skipped — add them with explicit insert_row operations.",
619
+ );
620
+ }
621
+ return ops;
622
+ };
623
+
624
+ const ops: DiffEdit[] = [];
625
+ for (const [tag, i1, i2, j1, j2] of opcodes) {
626
+ if (tag === "equal") continue;
627
+ if (tag === "replace") {
628
+ const pairs = Math.min(i2 - i1, j2 - j1);
629
+ for (let k = 0; k < pairs; k++) {
630
+ const o_txt = row_text(rows_o[i1 + k]);
631
+ const m_txt = row_text(rows_m[j1 + k]);
632
+ if (o_txt !== m_txt) {
633
+ ops.push({
634
+ type: "modify",
635
+ target_text: o_txt,
636
+ new_text: m_txt,
637
+ comment: "Diff: Table row modified",
638
+ });
639
+ }
640
+ }
641
+ for (let k = i1 + pairs; k < i2; k++) {
642
+ ops.push({
643
+ type: "delete_row",
644
+ target_text: row_text(rows_o[k]),
645
+ _match_start_index: rows_o[k].start,
646
+ });
647
+ }
648
+ const surplus_new = rows_m.slice(j1 + pairs, j2);
649
+ if (surplus_new.length > 0) {
650
+ ops.push(...insert_ops(surplus_new, i2));
651
+ }
652
+ } else if (tag === "delete") {
653
+ for (let k = i1; k < i2; k++) {
654
+ ops.push({
655
+ type: "delete_row",
656
+ target_text: row_text(rows_o[k]),
657
+ _match_start_index: rows_o[k].start,
658
+ });
659
+ }
660
+ } else if (tag === "insert") {
661
+ ops.push(...insert_ops(rows_m.slice(j1, j2), i1));
662
+ }
663
+ }
664
+ return ops;
665
+ }
666
+
667
+ /**
668
+ * DOCX-to-DOCX diff over projections extracted with return_structure=true.
669
+ *
670
+ * Improvements over a flat generate_edits_from_text pass:
671
+ * - each OPC part (header/body/footer/notes) diffs against its
672
+ * counterpart, so no edit can span a part boundary and content that
673
+ * moved between parts is reported instead of hidden (QA 2026-07-18 C1);
674
+ * - table row insertions/deletions become structured insert_row /
675
+ * delete_row operations instead of pipe-text edits (QA C2).
676
+ *
677
+ * Every ModifyText keeps its _match_start_index pinned into text_orig, which
678
+ * the engine consumes positionally (the Node engine has no
679
+ * make_edits_self_contained JSON round trip like the Python CLI).
680
+ *
681
+ * Returns { edits, warnings }. Warnings describe fallbacks the caller should
682
+ * surface (differing part layouts, unanchorable rows, …).
683
+ */
684
+ export function generate_structured_edits(
685
+ text_orig: string,
686
+ struct_orig: ExtractStructure,
687
+ text_mod: string,
688
+ struct_mod: ExtractStructure,
689
+ ): { edits: DiffEdit[]; warnings: string[] } {
690
+ const warnings: string[] = [];
691
+ const edits: DiffEdit[] = [];
692
+
693
+ const kinds_o = struct_orig.part_ranges
694
+ .filter(([s, e]) => e > s)
695
+ .map(([, , k]) => k);
696
+ const kinds_m = struct_mod.part_ranges
697
+ .filter(([s, e]) => e > s)
698
+ .map(([, , k]) => k);
699
+
700
+ if (JSON.stringify(kinds_o) !== JSON.stringify(kinds_m)) {
701
+ warnings.push(
702
+ `The documents have different part layouts (${kinds_o.join(" + ") || "none"} vs ` +
703
+ `${kinds_m.join(" + ") || "none"}); comparing flattened text instead. Header/footer ` +
704
+ "additions or removals cannot be expressed as text edits.",
705
+ );
706
+ const flat = _drop_image_marker_hunks(
707
+ generate_edits_from_text(text_orig, text_mod),
708
+ warnings,
709
+ );
710
+ return { edits: [...flat], warnings };
711
+ }
712
+
713
+ const ranges_o = struct_orig.part_ranges
714
+ .filter(([s, e]) => e > s)
715
+ .map(([s, e]) => [s, e] as [number, number]);
716
+ const ranges_m = struct_mod.part_ranges
717
+ .filter(([s, e]) => e > s)
718
+ .map(([s, e]) => [s, e] as [number, number]);
719
+
720
+ for (let p = 0; p < ranges_o.length; p++) {
721
+ const [po_start, po_end] = ranges_o[p];
722
+ const [pm_start, pm_end] = ranges_m[p];
723
+
724
+ const tables_o = struct_orig.tables.filter(
725
+ (t) => po_start <= t.start && t.end <= po_end,
726
+ );
727
+ const tables_m = struct_mod.tables.filter(
728
+ (t) => pm_start <= t.start && t.end <= pm_end,
729
+ );
730
+
731
+ const tables_alignable =
732
+ tables_o.length === tables_m.length &&
733
+ tables_o.every((t) => _rows_are_plain(text_orig, t)) &&
734
+ tables_m.every((t) => _rows_are_plain(text_mod, t));
735
+ if (tables_o.length !== tables_m.length) {
736
+ warnings.push(
737
+ `A ${kinds_o[p]} part has ${tables_o.length} table(s) in the ` +
738
+ `original but ${tables_m.length} in the modified document; its tables were compared as plain ` +
739
+ "text. Adding or removing whole tables is not supported via diff/apply.",
740
+ );
741
+ }
742
+
743
+ if (!tables_alignable) {
744
+ const part_edits = generate_edits_from_text(
745
+ text_orig.substring(po_start, po_end),
746
+ text_mod.substring(pm_start, pm_end),
747
+ );
748
+ for (const e of part_edits) {
749
+ e._match_start_index = (e._match_start_index || 0) + po_start;
750
+ }
751
+ edits.push(...part_edits);
752
+ continue;
753
+ }
754
+
755
+ // Walk interleaved segments: text-before-table, table, text-after…
756
+ const boundaries_o: [number, number][] = [
757
+ [po_start, po_start],
758
+ ...tables_o.map((t) => [t.start, t.end] as [number, number]),
759
+ [po_end, po_end],
760
+ ];
761
+ const boundaries_m: [number, number][] = [
762
+ [pm_start, pm_start],
763
+ ...tables_m.map((t) => [t.start, t.end] as [number, number]),
764
+ [pm_end, pm_end],
765
+ ];
766
+
767
+ for (let seg_idx = 0; seg_idx < boundaries_o.length - 1; seg_idx++) {
768
+ const seg_o_start = boundaries_o[seg_idx][1];
769
+ const seg_o_end = boundaries_o[seg_idx + 1][0];
770
+ const seg_m_start = boundaries_m[seg_idx][1];
771
+ const seg_m_end = boundaries_m[seg_idx + 1][0];
772
+ const seg_edits = generate_edits_from_text(
773
+ text_orig.substring(seg_o_start, seg_o_end),
774
+ text_mod.substring(seg_m_start, seg_m_end),
775
+ );
776
+ for (const e of seg_edits) {
777
+ e._match_start_index = (e._match_start_index || 0) + seg_o_start;
778
+ }
779
+ edits.push(...seg_edits);
780
+
781
+ if (seg_idx < tables_o.length) {
782
+ const t_o = tables_o[seg_idx];
783
+ const t_m = tables_m[seg_idx];
784
+ const row_opcodes = _table_row_opcodes(t_o.rows, t_m.rows);
785
+ if (row_opcodes !== null) {
786
+ edits.push(..._row_ops_for_table(t_o, t_m, row_opcodes, warnings));
787
+ } else {
788
+ const tbl_edits = generate_edits_from_text(
789
+ text_orig.substring(t_o.start, t_o.end),
790
+ text_mod.substring(t_m.start, t_m.end),
791
+ );
792
+ for (const e of tbl_edits) {
793
+ e._match_start_index = (e._match_start_index || 0) + t_o.start;
794
+ }
795
+ edits.push(...tbl_edits);
796
+ }
797
+ }
798
+ }
799
+ }
800
+
801
+ // Row operations anchor by row text (pins do not survive JSON): if the
802
+ // anchor text also appears elsewhere in the document — e.g. two tables
803
+ // sharing a header row — the strict text match at apply time is
804
+ // ambiguous. In-process consumers ride the pinned offsets; JSON consumers
805
+ // fail closed, so tell the user why up front.
806
+ const countOccurrences = (haystack: string, needle: string): number => {
807
+ let count = 0;
808
+ let from = 0;
809
+ while (true) {
810
+ const idx = haystack.indexOf(needle, from);
811
+ if (idx === -1) break;
812
+ count++;
813
+ from = idx + needle.length;
814
+ }
815
+ return count;
816
+ };
817
+ let ambiguous_anchor_warned = false;
818
+ for (const e of edits) {
819
+ if (
820
+ (e.type === "insert_row" || e.type === "delete_row") &&
821
+ !ambiguous_anchor_warned
822
+ ) {
823
+ if (e.target_text && countOccurrences(text_orig, e.target_text) > 1) {
824
+ warnings.push(
825
+ `The row anchor "${e.target_text.substring(0, 60)}" appears more than once in the document. ` +
826
+ "Applying this diff from its JSON output may be rejected as ambiguous — " +
827
+ "make the anchor rows unique, or apply the row changes with explicit " +
828
+ "insert_row/delete_row edits.",
829
+ );
830
+ ambiguous_anchor_warned = true;
831
+ }
832
+ }
833
+ }
834
+
835
+ // Our own output must never trip the engine's read-only image-marker
836
+ // validation: an added/removed image becomes a warning, not an edit.
837
+ const modify_edits = edits.filter(
838
+ (e): e is ModifyText => e.type === "modify",
839
+ );
840
+ const kept_modifies = new Set(_drop_image_marker_hunks(modify_edits, warnings));
841
+ const final_edits = edits.filter(
842
+ (e) => e.type !== "modify" || kept_modifies.has(e as ModifyText),
843
+ );
844
+
845
+ return { edits: final_edits, warnings };
846
+ }
847
+
349
848
  export function create_unified_diff(
350
849
  original_text: string,
351
850
  modified_text: string,
@@ -234,6 +234,19 @@ export class DocumentObject {
234
234
  public async save(): Promise<Buffer> {
235
235
  for (const part of this.pkg.parts) {
236
236
  let xmlStr = serializeXml(part._element.ownerDocument || part._element);
237
+ // Lazily declare the w16du namespace on any part that picked up a
238
+ // tracked change (w16du:dateUtc) without a root declaration — a
239
+ // tracked edit in a header/footer/footnotes part would otherwise
240
+ // serialize an undeclared prefix no parser (including ours) accepts.
241
+ // Unmodified parts never contain "w16du:" and stay untouched.
242
+ // Mirrors the Python engine's _inject_w16du_if_needed at save time.
243
+ if (xmlStr.includes("w16du:") && !xmlStr.includes("xmlns:w16du=")) {
244
+ part._element.setAttribute(
245
+ "xmlns:w16du",
246
+ "http://schemas.microsoft.com/office/word/2023/wordml/word16du",
247
+ );
248
+ xmlStr = serializeXml(part._element.ownerDocument || part._element);
249
+ }
237
250
  if (!xmlStr.startsWith("<?xml")) {
238
251
  xmlStr =
239
252
  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + xmlStr;
package/src/domain.ts CHANGED
@@ -42,6 +42,62 @@ function _get_paragraph_text(p: Paragraph): string {
42
42
  return text;
43
43
  }
44
44
 
45
+ const _TERM_BODY = "[A-Z][A-Za-z0-9\\s\\-&'’]{1,60}";
46
+ // Definition typography, matched repeatedly within a paragraph (QA 2026-07-18
47
+ // M7 — a paragraph defining Alpha, Beta AND Gamma must yield all three):
48
+ // 1. paragraph-leading quoted term (optionally after a numbering token)
49
+ // 2. sentence-leading quoted term (after . ; : ! ?)
50
+ // 3. parenthesized inline definition — (the "Term")
51
+ // The 'd' flag (ES2022 match indices) recovers each term's exact position for
52
+ // position-keyed dedupe, mirroring Python's m.start(1).
53
+ const _LEADING_TERM_RE = new RegExp(
54
+ `^(?:[\\d.\\-()a-zA-Z]+\\s*)?["“](${_TERM_BODY})["”]`,
55
+ "d",
56
+ );
57
+ // Like the leading pattern, a sentence-start definition may carry a numbering
58
+ // token ('… the product. 2.2 "Beta" means …').
59
+ const _SENTENCE_TERM_RE = new RegExp(
60
+ `(?<=[.;:!?])\\s+(?:[\\d.\\-()a-zA-Z]+\\s+)?["“](${_TERM_BODY})["”]`,
61
+ "dg",
62
+ );
63
+ const _INLINE_TERM_RE = new RegExp(
64
+ `\\([^)]*?["“](${_TERM_BODY})["”][^)]*?\\)`,
65
+ "dg",
66
+ );
67
+
68
+ /**
69
+ * All defined terms declared in one paragraph, in appearance order,
70
+ * deduplicated. Language-agnostic: keyed on quoting typography, never on
71
+ * English phrases like "means".
72
+ */
73
+ export function extract_terms_from_paragraph(text: string): string[] {
74
+ const found: [number, string][] = [];
75
+ const leading = _LEADING_TERM_RE.exec(text) as RegExpExecArray & {
76
+ indices?: Array<[number, number]>;
77
+ };
78
+ if (leading && leading.indices && leading.indices[1]) {
79
+ found.push([leading.indices[1][0], leading[1].trim()]);
80
+ }
81
+ for (const m of text.matchAll(_SENTENCE_TERM_RE)) {
82
+ const indices = (m as any).indices as Array<[number, number]> | undefined;
83
+ if (indices && indices[1]) found.push([indices[1][0], m[1].trim()]);
84
+ }
85
+ for (const m of text.matchAll(_INLINE_TERM_RE)) {
86
+ const indices = (m as any).indices as Array<[number, number]> | undefined;
87
+ if (indices && indices[1]) found.push([indices[1][0], m[1].trim()]);
88
+ }
89
+
90
+ const terms: string[] = [];
91
+ const seen_positions = new Set<number>();
92
+ found.sort((a, b) => a[0] - b[0]);
93
+ for (const [pos, term] of found) {
94
+ if (seen_positions.has(pos)) continue;
95
+ seen_positions.add(pos);
96
+ terms.push(term);
97
+ }
98
+ return terms;
99
+ }
100
+
45
101
  export function extract_all_domain_metadata(
46
102
  doc: DocumentObject,
47
103
  base_text: string,
@@ -58,26 +114,14 @@ export function extract_all_domain_metadata(
58
114
  > = {};
59
115
  const raw_references: [string, string][] = [];
60
116
 
61
- const leading_re =
62
- /^(?:[\d.\-()a-zA-Z]+\s*)?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”]/;
63
- const inline_re = /\([^)]*?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”][^)]*?\)/g;
64
-
65
117
  for (const item of iter_block_items(doc)) {
66
118
  if (!(item instanceof Paragraph)) continue;
67
119
 
68
120
  const text = _get_paragraph_text(item).trim();
69
121
  if (!text) continue;
70
122
 
71
- const extracted_terms: string[] = [];
72
- const leading_match = text.match(leading_re);
73
- if (leading_match) extracted_terms.push(leading_match[1].trim());
74
-
75
- const inline_matches = text.matchAll(inline_re);
76
- for (const m of inline_matches) {
77
- extracted_terms.push(m[1].trim());
78
- }
79
-
80
- for (const term of extracted_terms) {
123
+ // 1. Extract Definitions (every declaration in the paragraph — QA M7)
124
+ for (const term of extract_terms_from_paragraph(text)) {
81
125
  if (definitions[term]) duplicates.add(term);
82
126
  else definitions[term] = { count: 0 };
83
127
  }
@@ -134,10 +178,19 @@ export function extract_all_domain_metadata(
134
178
  if (definitions[matched_term]) definitions[matched_term].count++;
135
179
  }
136
180
 
181
+ // Drop unused terms from the SYMBOL TABLE only — the filter is noise
182
+ // reduction for the Defined Terms listing, and must not gate the
183
+ // Semantic Diagnostics: a term defined twice and never used is two
184
+ // drafting errors, not zero (QA 2026-07-17 F6; mirrors Python). Surface
185
+ // the orphan definition itself as a diagnostic instead.
137
186
  for (const term of def_keys) {
138
187
  if (definitions[term].count === 0) {
139
188
  delete definitions[term];
140
- duplicates.delete(term);
189
+ if (!duplicates.has(term)) {
190
+ diagnostics.push(
191
+ `[Warning] Unused Definition: '${term}' is defined but never used.`,
192
+ );
193
+ }
141
194
  }
142
195
  }
143
196
  }